From 988e85b695b0f518a840a9794d3fd12cc67e34b5 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Mon, 16 Feb 2026 15:43:52 +0100 Subject: [PATCH 01/13] Spyglass API generation plus tests --- Eventuous.slnx | 4 + .../Application/CommandService.cs | 6 +- .../Bookings.Payments.csproj | 4 + .../esdb/Bookings.Payments/Domain/Payment.cs | 10 +- samples/esdb/Bookings.Payments/Program.cs | 3 + samples/esdb/Bookings/Bookings.csproj | 1 + src/Core/src/Eventuous.Domain/State.cs | 12 +- .../src/Eventuous.Spyglass/Accessor.cs | 31 ----- .../Eventuous.Spyglass.csproj | 1 + .../src/Eventuous.Spyglass/InsidePeek.cs | 130 ------------------ .../RegistrationExtensions.cs | 10 +- .../src/Eventuous.Spyglass/SpyglassApi.cs | 48 ++++--- 12 files changed, 61 insertions(+), 199 deletions(-) delete mode 100644 src/Experimental/src/Eventuous.Spyglass/Accessor.cs delete mode 100644 src/Experimental/src/Eventuous.Spyglass/InsidePeek.cs diff --git a/Eventuous.slnx b/Eventuous.slnx index 9866ba984..ca70384c7 100644 --- a/Eventuous.slnx +++ b/Eventuous.slnx @@ -73,6 +73,9 @@ + + + @@ -81,6 +84,7 @@ + diff --git a/samples/esdb/Bookings.Payments/Application/CommandService.cs b/samples/esdb/Bookings.Payments/Application/CommandService.cs index 7f913659d..4e1c94066 100644 --- a/samples/esdb/Bookings.Payments/Application/CommandService.cs +++ b/samples/esdb/Bookings.Payments/Application/CommandService.cs @@ -5,12 +5,12 @@ namespace Bookings.Payments.Application; -public class CommandService : CommandService { +public class CommandService : CommandService { public CommandService(IEventStore store) : base(store) { On() .InState(ExpectedState.New) - .GetId(cmd => new(cmd.PaymentId)) - .Act((payment, cmd) => payment.ProcessPayment(cmd.BookingId, new(cmd.Amount, cmd.Currency), cmd.Method, cmd.Provider)); + .GetStream(cmd => GetStream(cmd.PaymentId)) + .Act(cmd => [new PaymentEvents.PaymentRecorded(cmd.BookingId, cmd.Amount, cmd.Currency, cmd.Method, cmd.Provider)]); } } diff --git a/samples/esdb/Bookings.Payments/Bookings.Payments.csproj b/samples/esdb/Bookings.Payments/Bookings.Payments.csproj index 42d100cf3..ff30478df 100644 --- a/samples/esdb/Bookings.Payments/Bookings.Payments.csproj +++ b/samples/esdb/Bookings.Payments/Bookings.Payments.csproj @@ -1,6 +1,8 @@ Debug;Release + true + obj/Generated @@ -30,5 +32,7 @@ + + \ No newline at end of file diff --git a/samples/esdb/Bookings.Payments/Domain/Payment.cs b/samples/esdb/Bookings.Payments/Domain/Payment.cs index 8dfa2ba01..57be2a332 100644 --- a/samples/esdb/Bookings.Payments/Domain/Payment.cs +++ b/samples/esdb/Bookings.Payments/Domain/Payment.cs @@ -1,12 +1,8 @@ using Eventuous; -using static Bookings.Payments.Domain.PaymentEvents; namespace Bookings.Payments.Domain; -public class Payment : Aggregate { - public void ProcessPayment(string bookingId, Money amount, string method, string provider) - => Apply(new PaymentRecorded(bookingId, amount.Amount, amount.Currency, method, provider)); -} +using static PaymentEvents; public record PaymentState : State { public string BookingId { get; init; } = null!; @@ -15,6 +11,4 @@ public record PaymentState : State { public PaymentState() { On((state, recorded) => state with { BookingId = recorded.BookingId, Amount = recorded.Amount }); } -} - -public record PaymentId(string Value) : Id(Value); +} \ No newline at end of file diff --git a/samples/esdb/Bookings.Payments/Program.cs b/samples/esdb/Bookings.Payments/Program.cs index 768cfad12..fd9fe2272 100644 --- a/samples/esdb/Bookings.Payments/Program.cs +++ b/samples/esdb/Bookings.Payments/Program.cs @@ -1,6 +1,7 @@ using Bookings.Payments; using Bookings.Payments.Domain; using Bookings.Payments.Infrastructure; +using Eventuous.Spyglass; using Serilog; Logging.ConfigureLog(); @@ -27,4 +28,6 @@ app.UseSwaggerUI(); +app.MapEventuousSpyglass(); + app.Run(); \ No newline at end of file diff --git a/samples/esdb/Bookings/Bookings.csproj b/samples/esdb/Bookings/Bookings.csproj index afb16294c..fe65e329e 100644 --- a/samples/esdb/Bookings/Bookings.csproj +++ b/samples/esdb/Bookings/Bookings.csproj @@ -32,6 +32,7 @@ + diff --git a/src/Core/src/Eventuous.Domain/State.cs b/src/Core/src/Eventuous.Domain/State.cs index cce5d8b05..c54fbf6e0 100644 --- a/src/Core/src/Eventuous.Domain/State.cs +++ b/src/Core/src/Eventuous.Domain/State.cs @@ -3,13 +3,10 @@ namespace Eventuous; -[Obsolete("Use State instead")] -public abstract record AggregateState : State where T : AggregateState; - [PublicAPI] public abstract record State where T : State { /// - /// Function to apply event to the state object. + /// Function to apply an event to the state object. /// /// Event to apply /// New instance of state @@ -24,7 +21,7 @@ public virtual T When(object @event) { /// /// Event handler that uses the event payload and creates a new instance of state using the data from the event. /// - /// Function to return new state instance after the event is applied + /// Function to return a new state instance after the event is applied /// Event type /// Thrown if another function already handles this event type [PublicAPI] @@ -36,6 +33,11 @@ protected void On(Func handle) { } } + /// + /// Returns the event types that have registered handlers in this state. + /// + public ICollection RegisteredEventTypes => _handlers.Keys; + readonly Dictionary> _handlers = new(); } diff --git a/src/Experimental/src/Eventuous.Spyglass/Accessor.cs b/src/Experimental/src/Eventuous.Spyglass/Accessor.cs deleted file mode 100644 index c9906bdda..000000000 --- a/src/Experimental/src/Eventuous.Spyglass/Accessor.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (C) Eventuous HQ OÜ. All rights reserved -// Licensed under the Apache License, Version 2.0. - -using System.Reflection; - -namespace Eventuous.Spyglass; - -static class Accessor { - extension(object instance) { - public object? GetPrivateMember(string name) => GetMember(instance.GetType(), instance, name); - - public TMember? GetPrivateMember(string name) where TMember : class - => GetMember(instance.GetType(), instance, name); - } - - static TMember? GetMember(Type instanceType, object instance, string name) where TMember : class - => GetMember(instanceType, instance, name) as TMember; - - static object? GetMember(Type instanceType, object instance, string name) { - const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static; - - var field = instanceType.GetField(name, flags); - var prop = instanceType.GetProperty(name, flags); - var member = prop?.GetValue(instance) ?? field?.GetValue(instance); - - return member == null && instanceType.BaseType != null - // ReSharper disable once TailRecursiveCall - ? GetMember(instanceType.BaseType, instance, name) - : member; - } -} diff --git a/src/Experimental/src/Eventuous.Spyglass/Eventuous.Spyglass.csproj b/src/Experimental/src/Eventuous.Spyglass/Eventuous.Spyglass.csproj index 182ad907d..347e5ea6f 100644 --- a/src/Experimental/src/Eventuous.Spyglass/Eventuous.Spyglass.csproj +++ b/src/Experimental/src/Eventuous.Spyglass/Eventuous.Spyglass.csproj @@ -6,5 +6,6 @@ + diff --git a/src/Experimental/src/Eventuous.Spyglass/InsidePeek.cs b/src/Experimental/src/Eventuous.Spyglass/InsidePeek.cs deleted file mode 100644 index e71f21728..000000000 --- a/src/Experimental/src/Eventuous.Spyglass/InsidePeek.cs +++ /dev/null @@ -1,130 +0,0 @@ -// Copyright (C) Eventuous HQ OÜ. All rights reserved -// Licensed under the Apache License, Version 2.0. - -using System.Reflection; -using Microsoft.Extensions.Logging; - -// ReSharper disable ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator - -namespace Eventuous.Spyglass; - -public class InsidePeek { - readonly IEventStore _eventStore; - readonly ILogger _log; - readonly AggregateFactoryRegistry _registry; - - [PublicAPI] - public InsidePeek(IEventStore eventStore, ILogger log) : this(AggregateFactoryRegistry.Instance, eventStore, log) { } - - public InsidePeek(AggregateFactoryRegistry registry, IEventStore eventStore, ILogger log) { - _eventStore = eventStore; - _log = log; - _registry = registry; - var assemblies = AppDomain.CurrentDomain.GetAssemblies(); - - foreach (var assembly in assemblies) { - Scan(assembly); - } - } - - void Scan(Assembly assembly) { - if (assembly.IsDynamic) { - return; - } - - var aggregateType = typeof(Aggregate<>); - - var cl = assembly - .ExportedTypes - .Where(x => DeepBaseType(x, aggregateType) && !x.IsAbstract) - .ToList(); - - var reg = _registry.Registry; - - foreach (var type in cl) { - var stateType = GetStateType(type); - - if (stateType == null) continue; - - var methods = (type as dynamic).DeclaredMethods as MethodInfo[]; - - AggregateInfos.Add(new(type, stateType, methods!, () => CreateInstance(reg, type))); - } - - return; - - Type? GetStateType(Type type) - => type.BaseType!.GenericTypeArguments.Length == 0 - ? null - : type.BaseType!.GenericTypeArguments[0]; - } - - static dynamic CreateInstance(Dictionary> reg, Type aggregateType) { - var instance = reg.TryGetValue(aggregateType, out var factory) - ? factory() - : Activator.CreateInstance(aggregateType)!; - - return instance; - } - - public async Task Load(string streamName, int version) { - var typeName = streamName[..streamName.IndexOf('-')]; - var agg = AggregateInfos.First(x => x.AggregateType == typeName); - var events = await _eventStore.ReadStream(new(streamName), StreamReadPosition.Start, true, CancellationToken.None); - var aggregate = agg.GetAggregate(); - var selectedEvents = version == -1 ? events : events.Take(version + 1); - aggregate.Load(selectedEvents.Select(x => x.Payload)); - - return new { aggregate.State, Events = events.Select(x => new { EventType = x.Payload!.GetType().Name, x.Payload }) }; - } - - public object[] Aggregates => AggregateInfos.Select(x => x.GetInfo()).ToArray(); - - List AggregateInfos { get; } = []; - - static bool DeepBaseType(Type t, Type compareWith) { - while (true) { - if (t.BaseType == null) return false; - if (t.BaseType == compareWith) return true; - - t = t.BaseType; - } - } - - record AggregateInfo { - public AggregateInfo(Type aggregateType, Type stateType, MethodInfo[] methods, Func factory) { - _aggregateType = aggregateType; - _stateType = stateType; - _methods = methods; - _factory = factory; - } - - public dynamic GetAggregate() => _factory(); - - public object GetInfo() { - var instance = GetAggregate(); - object state = instance.State; - var handlers = state.GetPrivateMember("_handlers"); - var handlerType = typeof(Func<,,>).MakeGenericType(_stateType, typeof(object), _stateType); - var handlersDicType = typeof(Dictionary<,>).MakeGenericType(typeof(Type), handlerType); - dynamic handlersDic = Convert.ChangeType(handlers, handlersDicType)!; - IEnumerable keys = handlersDic.Keys; - - return new { - Type = _aggregateType.Name, - StateType = _stateType.Name, - Methods = _methods.Select(x => x.Name).ToArray(), - Events = keys.Select(x => x.Name).ToArray() - }; - } - - public override string ToString() => $"{_aggregateType.Name} ({_stateType.Name})"; - - readonly Type _aggregateType; - readonly Type _stateType; - readonly MethodInfo[] _methods; - readonly Func _factory; - - public string AggregateType => _aggregateType.Name; - } -} diff --git a/src/Experimental/src/Eventuous.Spyglass/RegistrationExtensions.cs b/src/Experimental/src/Eventuous.Spyglass/RegistrationExtensions.cs index b04b98461..7e8afe447 100644 --- a/src/Experimental/src/Eventuous.Spyglass/RegistrationExtensions.cs +++ b/src/Experimental/src/Eventuous.Spyglass/RegistrationExtensions.cs @@ -1,4 +1,4 @@ -// Copyright (C) 2021-2022 Eventuous HQ OÜ. All rights reserved +// Copyright (C) Eventuous HQ OÜ. All rights reserved // Licensed under the Apache License, Version 2.0. using Microsoft.Extensions.DependencyInjection; @@ -6,6 +6,10 @@ namespace Eventuous.Spyglass; public static class RegistrationExtensions { - public static IServiceCollection AddEventuousSpyglass(this IServiceCollection services) - => services.AddSingleton(); + /// + /// Kept for API compatibility. The Spyglass registry is populated automatically + /// via a source-generated module initializer; no DI registration is required. + /// + [Obsolete("No longer required. The Spyglass registry is populated automatically.")] + public static IServiceCollection AddEventuousSpyglass(this IServiceCollection services) => services; } diff --git a/src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs b/src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs index 5e4f5137c..ac60dd505 100644 --- a/src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs +++ b/src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs @@ -23,12 +23,27 @@ public static class SpyglassApi { /// [PublicAPI] public static IEndpointRouteBuilder MapEventuousSpyglass(this IEndpointRouteBuilder builder, string? key) { + var logger = builder.ServiceProvider.GetRequiredService>(); + + if (!builder.ServiceProvider.GetRequiredService().IsDevelopment() && key == null) { + logger.LogWarning("Insecure Spyglass API is only available in development environment"); + key = Guid.NewGuid().ToString("N"); + logger.LogInformation("Using generated key: {Key}", key); + } + + if (key == null) { + logger.LogWarning("Spyglass API is not secured, ensure that it's not exposed to the Internet"); + } + builder.MapGet("/spyglass/ping", (HttpRequest request) => CheckAndReturn(request, () => "Okay")) .ExcludeFromDescription(); builder.MapGet( "/spyglass/aggregates", - (HttpRequest request, [FromServices] InsidePeek peek) => CheckAndReturn(request, () => peek.Aggregates) + (HttpRequest request) => CheckAndReturn( + request, + () => SpyglassRegistry.GetAggregates().Select(a => new { Type = a.AggregateType, a.StateType, a.Methods, a.Events }).ToArray() + ) ) .ExcludeFromDescription(); @@ -46,16 +61,23 @@ public static IEndpointRouteBuilder MapEventuousSpyglass(this IEndpointRouteBuil builder.MapGet( "/spyglass/load/{streamName}", - (HttpRequest request, [FromServices] InsidePeek peek, string streamName, [FromQuery] int version) - => CheckAndReturnAsync(request, () => peek.Load(streamName, version)) + async (HttpRequest request, [FromServices] IEventStore eventStore, string streamName, [FromQuery] int version) => { + if (!Authorized(request)) return Results.Unauthorized(); + + var typeName = streamName[..streamName.IndexOf('-')]; + var aggInfo = SpyglassRegistry.FindByTypeName(typeName); + + if (aggInfo is null) return Results.NotFound($"Aggregate type '{typeName}' not found"); + + var result = await aggInfo.LoadDelegate(eventStore, streamName, version); + + return Results.Ok(new { result.State, Events = result.Events.Select(e => new { e.EventType, e.Payload }) }); + } ) .ExcludeFromDescription(); return builder; - async Task CheckAndReturnAsync(HttpRequest request, Func> getResult) - => Authorized(request) ? Results.Ok(await getResult()) : Results.Unauthorized(); - IResult CheckAndReturn(HttpRequest request, Func getResult) => Authorized(request) ? Results.Ok(getResult()) : Results.Unauthorized(); @@ -72,23 +94,11 @@ bool Authorized(HttpRequest request) /// [PublicAPI] public static IApplicationBuilder MapEventuousSpyglass(this IApplicationBuilder app, string? key = null) { - var logger = app.ApplicationServices.GetRequiredService>(); - - if (!app.ApplicationServices.GetRequiredService().IsDevelopment() && key == null) { - logger.LogWarning("Insecure Spyglass API is only available in development environment"); - key = Guid.NewGuid().ToString("N"); - logger.LogInformation("Using generated key: {Key}", key); - } - - if (key == null) { - logger.LogWarning("Spyglass API is not secured, ensure that it's not exposed to the Internet"); - } - if (!app.Properties.ContainsKey(EndpointRouteBuilder)) { app.UseRouting(); } - app.UseEndpoints(builder => MapEventuousSpyglass(builder, key)); + app.UseEndpoints(builder => builder.MapEventuousSpyglass(key)); return app; } From 834d4aa62a397bf65df278f7fc05b5a0c40f673a Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Mon, 16 Feb 2026 15:44:06 +0100 Subject: [PATCH 02/13] Added missing files --- .../Constants.cs | 11 + .../Eventuous.Spyglass.Generators.csproj | 12 + .../Eventuous.Spyglass.Generators/README.md | 87 ++++++ .../SpyglassGenerator.cs | 281 ++++++++++++++++++ .../Eventuous.Spyglass/SpyglassRegistry.cs | 37 +++ .../CompilationHelper.cs | 57 ++++ ...Eventuous.Tests.Spyglass.Generators.csproj | 15 + .../SpyglassGeneratorTests.cs | 139 +++++++++ 8 files changed, 639 insertions(+) create mode 100644 src/Experimental/gen/Eventuous.Spyglass.Generators/Constants.cs create mode 100644 src/Experimental/gen/Eventuous.Spyglass.Generators/Eventuous.Spyglass.Generators.csproj create mode 100644 src/Experimental/gen/Eventuous.Spyglass.Generators/README.md create mode 100644 src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs create mode 100644 src/Experimental/src/Eventuous.Spyglass/SpyglassRegistry.cs create mode 100644 src/Experimental/test/Eventuous.Tests.Spyglass.Generators/CompilationHelper.cs create mode 100644 src/Experimental/test/Eventuous.Tests.Spyglass.Generators/Eventuous.Tests.Spyglass.Generators.csproj create mode 100644 src/Experimental/test/Eventuous.Tests.Spyglass.Generators/SpyglassGeneratorTests.cs diff --git a/src/Experimental/gen/Eventuous.Spyglass.Generators/Constants.cs b/src/Experimental/gen/Eventuous.Spyglass.Generators/Constants.cs new file mode 100644 index 000000000..143c045b0 --- /dev/null +++ b/src/Experimental/gen/Eventuous.Spyglass.Generators/Constants.cs @@ -0,0 +1,11 @@ +// Copyright (C) Eventuous HQ OÜ. All rights reserved +// Licensed under the Apache License, Version 2.0. + +namespace Eventuous.Spyglass.Generators; + +internal static class Constants { + public const string BaseNamespace = "Eventuous"; + public const string AggregateFqn = $"{BaseNamespace}.Aggregate"; + public const string StateFqn = $"{BaseNamespace}.State"; + public const string OnMethodName = "On"; +} diff --git a/src/Experimental/gen/Eventuous.Spyglass.Generators/Eventuous.Spyglass.Generators.csproj b/src/Experimental/gen/Eventuous.Spyglass.Generators/Eventuous.Spyglass.Generators.csproj new file mode 100644 index 000000000..26909f2d4 --- /dev/null +++ b/src/Experimental/gen/Eventuous.Spyglass.Generators/Eventuous.Spyglass.Generators.csproj @@ -0,0 +1,12 @@ + + + netstandard2.0 + false + false + true + + + + + + diff --git a/src/Experimental/gen/Eventuous.Spyglass.Generators/README.md b/src/Experimental/gen/Eventuous.Spyglass.Generators/README.md new file mode 100644 index 000000000..cfebf515c --- /dev/null +++ b/src/Experimental/gen/Eventuous.Spyglass.Generators/README.md @@ -0,0 +1,87 @@ +# Eventuous.Spyglass.Generators + +A Roslyn incremental source generator that discovers Eventuous aggregates and states at compile time, replacing the previous reflection-based runtime discovery. + +## What it does + +The generator scans the compilation (including referenced assemblies) for: + +1. **Aggregate-based types** — concrete classes inheriting `Aggregate`. For each, it collects the aggregate name, state type, public instance methods, and handled event types. +2. **Standalone state types** — concrete `State` subclasses (classes or records) that are _not_ associated with any discovered aggregate. This enables support for the functional/aggregate-less `CommandService` pattern. + +It emits a `[ModuleInitializer]` that registers all discovered types into the static `SpyglassRegistry` at application startup, with no DI registration required. + +## Generated code + +For each discovered type the generator emits a `SpyglassRegistry.Register(...)` call containing: + +- **Aggregate name** (or `null` for standalone states) +- **State type name** +- **Public methods** (aggregate commands; empty for standalone states) +- **Handled event types** — resolved by instantiating the state and reading `RegisteredEventTypes` +- **Load delegate** — a lambda that reads events from the store and rehydrates state: + - For aggregates: creates the aggregate, calls `aggregate.Load(...)` + - For standalone states: folds events with `state.When(e)` + +### Example: aggregate + +```csharp +// Discovered from: public class Booking : Aggregate +SpyglassRegistry.Register(new SpyglassAggregateInfo( + "Booking", + "BookingState", + new string[] { "BookRoom", "RecordPayment" }, + new BookingState().RegisteredEventTypes.Select(t => t.Name).ToArray(), + static async (eventStore, streamName, version) => { + var aggregate = new Booking(); + var events = await eventStore.ReadStream(...); + aggregate.Load(...); + return new SpyglassLoadResult(aggregate.State, ...); + } +)); +``` + +### Example: standalone state (functional service) + +```csharp +// Discovered from: public record PaymentState : State +// (no Payment aggregate class exists) +SpyglassRegistry.Register(new SpyglassAggregateInfo( + null, + "PaymentState", + System.Array.Empty(), + new PaymentState().RegisteredEventTypes.Select(t => t.Name).ToArray(), + static async (eventStore, streamName, version) => { + var events = await eventStore.ReadStream(...); + var state = selected.Aggregate(new PaymentState(), (s, e) => s.When(e)); + return new SpyglassLoadResult(state, ...); + } +)); +``` + +## How to use + +Reference the `Eventuous.Spyglass` package (or project). The generator is bundled with it automatically. + +When using project references directly (not NuGet), add both: + +```xml + + +``` + +Then map the Spyglass API endpoints in your app: + +```csharp +app.MapEventuousSpyglass(); +``` + +To inspect the generated source, add to your `.csproj`: + +```xml +true +obj/Generated +``` + +The generated file will appear at `obj/Generated/.../SpyglassModule_{AssemblyName}.g.cs`. diff --git a/src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs b/src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs new file mode 100644 index 000000000..e4531beb1 --- /dev/null +++ b/src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs @@ -0,0 +1,281 @@ +// Copyright (C) Eventuous HQ OÜ. All rights reserved +// Licensed under the Apache License, Version 2.0. + +using System.Collections.Immutable; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using static Eventuous.Spyglass.Generators.Constants; + +namespace Eventuous.Spyglass.Generators; + +[Generator(LanguageNames.CSharp)] +public sealed class SpyglassGenerator : IIncrementalGenerator { + public void Initialize(IncrementalGeneratorInitializationContext context) { + var data = context.CompilationProvider.Select(static (compilation, _) => { + var assemblyName = compilation.AssemblyName ?? "UnknownAssembly"; + var aggregates = DiscoverAggregates(compilation); + var states = DiscoverStandaloneStates(compilation, aggregates); + + return (assemblyName, aggregates, states); + }); + + context.RegisterSourceOutput(data, static (spc, d) => Generate(spc, d.assemblyName, d.aggregates, d.states)); + } + + static ImmutableArray DiscoverAggregates(Compilation compilation) { + var builder = ImmutableArray.CreateBuilder(); + + ProcessNamespace(compilation.Assembly.GlobalNamespace, builder); + + foreach (var ra in compilation.SourceModule.ReferencedAssemblySymbols) { + ProcessNamespace(ra.GlobalNamespace, builder); + } + + return builder.ToImmutable(); + } + + static ImmutableArray DiscoverStandaloneStates( + Compilation compilation, + ImmutableArray aggregates + ) { + var aggregateStateFqns = new HashSet(aggregates.Select(a => a.StateFqn)); + var builder = ImmutableArray.CreateBuilder(); + + ProcessNamespaceForStates(compilation.Assembly.GlobalNamespace, builder, aggregateStateFqns); + + foreach (var ra in compilation.SourceModule.ReferencedAssemblySymbols) { + ProcessNamespaceForStates(ra.GlobalNamespace, builder, aggregateStateFqns); + } + + return builder.ToImmutable(); + } + + static void ProcessNamespaceForStates(INamespaceSymbol ns, ImmutableArray.Builder builder, HashSet excludeFqns) { + foreach (var member in ns.GetMembers()) { + switch (member) { + case INamespaceSymbol child: + ProcessNamespaceForStates(child, builder, excludeFqns); + + break; + case INamedTypeSymbol type: + ProcessTypeForState(type, builder, excludeFqns); + + break; + } + } + } + + static void ProcessTypeForState(INamedTypeSymbol type, ImmutableArray.Builder builder, HashSet excludeFqns) { + if (!type.IsAbstract && (type.TypeKind == TypeKind.Class || type.IsRecord)) { + for (var bt = type.BaseType; bt is not null; bt = bt.BaseType) { + if (bt.OriginalDefinition.ToDisplayString() == StateFqn && bt.TypeArguments.Length == 1) { + var stateFqn = MakeGlobal(type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + + if (!excludeFqns.Contains(stateFqn)) { + builder.Add(new StateCandidate(stateFqn, type.Name)); + } + + break; + } + } + } + + foreach (var nested in type.GetTypeMembers()) { + ProcessTypeForState(nested, builder, excludeFqns); + } + } + + static void ProcessNamespace(INamespaceSymbol ns, ImmutableArray.Builder builder) { + foreach (var member in ns.GetMembers()) { + switch (member) { + case INamespaceSymbol child: + ProcessNamespace(child, builder); + + break; + case INamedTypeSymbol type: + ProcessType(type, builder); + + break; + } + } + } + + static void ProcessType(INamedTypeSymbol type, ImmutableArray.Builder builder) { + if (!type.IsAbstract && type.TypeKind == TypeKind.Class) { + INamedTypeSymbol? stateType = null; + + for (var bt = type.BaseType; bt is not null; bt = bt.BaseType) { + if (bt.OriginalDefinition.ToDisplayString() == AggregateFqn && bt.TypeArguments.Length == 1) { + stateType = bt.TypeArguments[0] as INamedTypeSymbol; + + break; + } + } + + if (stateType is not null) { + var methods = type.GetMembers() + .OfType() + .Where(static m => + m.MethodKind == MethodKind.Ordinary + && m.DeclaredAccessibility == Accessibility.Public + && !m.IsStatic + && !m.IsImplicitlyDeclared) + .Select(static m => m.Name) + .Distinct() + .ToImmutableArray(); + + var aggregateFqn = MakeGlobal(type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + var stateFqn = MakeGlobal(stateType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + + builder.Add(new AggregateCandidate(aggregateFqn, type.Name, stateFqn, stateType.Name, methods)); + } + } + + foreach (var nested in type.GetTypeMembers()) { + ProcessType(nested, builder); + } + } + + static void Generate( + SourceProductionContext spc, + string assemblyName, + ImmutableArray aggregates, + ImmutableArray states + ) { + if (aggregates.IsDefaultOrEmpty && states.IsDefaultOrEmpty) { + const string marker = "// SpyglassGenerator found no aggregates or states. \n"; + spc.AddSource("Spyglass.Info.g.cs", marker); + + return; + } + + var safeAssembly = SanitizeIdentifier(assemblyName); + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine("using System.Runtime.CompilerServices;"); + sb.AppendLine("using Eventuous;"); + sb.AppendLine("using Eventuous.Spyglass;"); + sb.AppendLine(); + sb.AppendLine("namespace Eventuous.Spyglass.Generated;"); + sb.AppendLine(); + sb.AppendLine($"internal static class SpyglassModule_{safeAssembly} {{"); + sb.AppendLine(" [ModuleInitializer]"); + sb.AppendLine(" internal static void Initialize() {"); + + if (!aggregates.IsDefaultOrEmpty) { + foreach (var c in aggregates.Distinct()) { + sb.AppendLine(" SpyglassRegistry.Register(new SpyglassAggregateInfo("); + sb.AppendLine($" \"{Escape(c.AggregateSimpleName)}\","); + sb.AppendLine($" \"{Escape(c.StateSimpleName)}\","); + EmitStringArray(sb, c.Methods); + sb.AppendLine($" new {c.StateFqn}().RegisteredEventTypes.Select(t => t.Name).ToArray(),"); + sb.AppendLine(" static async (eventStore, streamName, version) => {"); + sb.AppendLine($" var aggregate = new {c.AggregateFqn}();"); + sb.AppendLine(" var events = await eventStore.ReadStream(new StreamName(streamName), StreamReadPosition.Start, true, default);"); + sb.AppendLine(" var selected = version == -1 ? events : events.Take(version + 1).ToArray();"); + sb.AppendLine(" aggregate.Load(selected.Length > 0 ? selected[^1].Revision : -1, selected.Select(x => x.Payload));"); + sb.AppendLine(" return new SpyglassLoadResult("); + sb.AppendLine(" aggregate.State,"); + sb.AppendLine(" events.Select(x => new SpyglassEventInfo(x.Payload!.GetType().Name, x.Payload)).ToArray());"); + sb.AppendLine(" }"); + sb.AppendLine(" ));"); + } + } + + if (!states.IsDefaultOrEmpty) { + foreach (var s in states.Distinct()) { + sb.AppendLine(" SpyglassRegistry.Register(new SpyglassAggregateInfo("); + sb.AppendLine(" null,"); + sb.AppendLine($" \"{Escape(s.StateSimpleName)}\","); + sb.AppendLine(" System.Array.Empty(),"); + sb.AppendLine($" new {s.StateFqn}().RegisteredEventTypes.Select(t => t.Name).ToArray(),"); + sb.AppendLine(" static async (eventStore, streamName, version) => {"); + sb.AppendLine(" var events = await eventStore.ReadStream(new StreamName(streamName), StreamReadPosition.Start, true, default);"); + sb.AppendLine(" var selected = version == -1 ? events : events.Take(version + 1).ToArray();"); + sb.AppendLine($" var state = selected.Select(x => x.Payload!).Aggregate(new {s.StateFqn}(), (s, e) => s.When(e));"); + sb.AppendLine(" return new SpyglassLoadResult("); + sb.AppendLine(" state,"); + sb.AppendLine(" events.Select(x => new SpyglassEventInfo(x.Payload!.GetType().Name, x.Payload)).ToArray());"); + sb.AppendLine(" }"); + sb.AppendLine(" ));"); + } + } + + sb.AppendLine(" }"); + sb.AppendLine("}"); + + spc.AddSource($"SpyglassModule_{safeAssembly}.g.cs", sb.ToString()); + } + + static void EmitStringArray(StringBuilder sb, ImmutableArray items) { + if (items.IsDefaultOrEmpty) { + sb.AppendLine(" System.Array.Empty(),"); + } + else { + sb.Append(" new string[] { "); + sb.Append(string.Join(", ", items.Select(i => $"\"{Escape(i)}\""))); + sb.AppendLine(" },"); + } + } + + static string Escape(string s) => s.Replace("\\", @"\\").Replace("\"", "\\\""); + + static string MakeGlobal(string typeName) => !typeName.StartsWith("global::") ? $"global::{typeName}" : typeName; + + static string SanitizeIdentifier(string name) { + var sb = new StringBuilder(name.Length); + + foreach (var ch in name) { + if (ch is '.' or '-' or '+') sb.Append('_'); + else if (SyntaxFacts.IsIdentifierPartCharacter(ch)) sb.Append(ch); + else sb.Append('_'); + } + + if (sb.Length == 0 || !SyntaxFacts.IsIdentifierStartCharacter(sb[0])) sb.Insert(0, '_'); + + return sb.ToString(); + } + + readonly struct StateCandidate : IEquatable { + public readonly string StateFqn; + public readonly string StateSimpleName; + + public StateCandidate(string stateFqn, string stateSimpleName) { + StateFqn = stateFqn; + StateSimpleName = stateSimpleName; + } + + public bool Equals(StateCandidate other) => StateFqn == other.StateFqn; + + public override bool Equals(object obj) => obj is StateCandidate other && Equals(other); + + public override int GetHashCode() => StateFqn?.GetHashCode() ?? 0; + } + + readonly struct AggregateCandidate : IEquatable { + public readonly string AggregateFqn; + public readonly string AggregateSimpleName; + public readonly string StateFqn; + public readonly string StateSimpleName; + public readonly ImmutableArray Methods; + + public AggregateCandidate(string aggregateFqn, string aggregateSimpleName, string stateFqn, string stateSimpleName, ImmutableArray methods) { + AggregateFqn = aggregateFqn; + AggregateSimpleName = aggregateSimpleName; + StateFqn = stateFqn; + StateSimpleName = stateSimpleName; + Methods = methods; + } + + public bool Equals(AggregateCandidate other) + => AggregateFqn == other.AggregateFqn; + + public override bool Equals(object obj) + => obj is AggregateCandidate other && Equals(other); + + public override int GetHashCode() => AggregateFqn?.GetHashCode() ?? 0; + } +} diff --git a/src/Experimental/src/Eventuous.Spyglass/SpyglassRegistry.cs b/src/Experimental/src/Eventuous.Spyglass/SpyglassRegistry.cs new file mode 100644 index 000000000..0fbcd476a --- /dev/null +++ b/src/Experimental/src/Eventuous.Spyglass/SpyglassRegistry.cs @@ -0,0 +1,37 @@ +// Copyright (C) Eventuous HQ OÜ. All rights reserved +// Licensed under the Apache License, Version 2.0. + +namespace Eventuous.Spyglass; + +public delegate Task SpyglassLoadDelegate(IEventStore eventStore, string streamName, int version); + +[PublicAPI] +public record SpyglassAggregateInfo( + string? AggregateType, + string StateType, + string[] Methods, + string[] Events, + SpyglassLoadDelegate LoadDelegate +); + +[PublicAPI] +public record SpyglassLoadResult(object State, SpyglassEventInfo[] Events); + +[PublicAPI] +public record SpyglassEventInfo(string EventType, object? Payload); + +[PublicAPI] +public static class SpyglassRegistry { + static readonly List Aggregates = []; + + public static void Register(SpyglassAggregateInfo info) => Aggregates.Add(info); + + public static SpyglassAggregateInfo[] GetAggregates() => [.. Aggregates]; + + public static SpyglassAggregateInfo? FindByTypeName(string typeName) + => Aggregates.FirstOrDefault(x => x.AggregateType == typeName) + ?? Aggregates.FirstOrDefault(x => StripStateSuffix(x.StateType) == typeName); + + static string StripStateSuffix(string s) + => s.EndsWith("State") && s.Length > 5 ? s[..^5] : s; +} diff --git a/src/Experimental/test/Eventuous.Tests.Spyglass.Generators/CompilationHelper.cs b/src/Experimental/test/Eventuous.Tests.Spyglass.Generators/CompilationHelper.cs new file mode 100644 index 000000000..af486ffff --- /dev/null +++ b/src/Experimental/test/Eventuous.Tests.Spyglass.Generators/CompilationHelper.cs @@ -0,0 +1,57 @@ +using System.Reflection; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Eventuous.Spyglass.Generators; + +namespace Eventuous.Tests.Spyglass.Generators; + +static class CompilationHelper { + public static CSharpCompilation CreateCompilation(string source) { + var syntaxTree = CSharpSyntaxTree.ParseText(source, new(LanguageVersion.Preview)); + + var refs = new List { + MetadataReference.CreateFromFile(typeof(object).GetTypeInfo().Assembly.Location), + MetadataReference.CreateFromFile(typeof(Enumerable).GetTypeInfo().Assembly.Location), + MetadataReference.CreateFromFile(typeof(State<>).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Aggregate<>).Assembly.Location) + }; + + TryAddRef(refs, "System.Runtime"); + TryAddRef(refs, "System.Collections"); + TryAddRef(refs, "System.Linq"); + TryAddRef(refs, "System.Private.CoreLib"); + + return CSharpCompilation.Create( + assemblyName: "SpyglassGeneratorTestAssembly", + syntaxTrees: [syntaxTree], + references: refs, + options: new( + OutputKind.DynamicallyLinkedLibrary, + optimizationLevel: OptimizationLevel.Debug, + specificDiagnosticOptions: [new("CS1701", ReportDiagnostic.Suppress)] + ) + ); + } + + public static (string? GeneratedSource, Diagnostic[] Diagnostics) RunGenerator(CSharpCompilation compilation) { + var generator = new SpyglassGenerator().AsSourceGenerator(); + var parseOptions = (CSharpParseOptions)compilation.SyntaxTrees.First().Options; + var driver = CSharpGeneratorDriver.Create([generator], parseOptions: parseOptions); + + driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics); + + var generatedTree = outputCompilation.SyntaxTrees.FirstOrDefault(t => t.FilePath.Contains("SpyglassModule_")); + + return (generatedTree?.GetText().ToString(), diagnostics.ToArray()); + } + + static void TryAddRef(List refs, string asmName) { + try { + var asm = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => a.GetName().Name == asmName); + + if (asm != null) refs.Add(MetadataReference.CreateFromFile(asm.Location)); + } catch { + // ignore + } + } +} diff --git a/src/Experimental/test/Eventuous.Tests.Spyglass.Generators/Eventuous.Tests.Spyglass.Generators.csproj b/src/Experimental/test/Eventuous.Tests.Spyglass.Generators/Eventuous.Tests.Spyglass.Generators.csproj new file mode 100644 index 000000000..714b9f53c --- /dev/null +++ b/src/Experimental/test/Eventuous.Tests.Spyglass.Generators/Eventuous.Tests.Spyglass.Generators.csproj @@ -0,0 +1,15 @@ + + + Exe + true + + + + + + + + + + + diff --git a/src/Experimental/test/Eventuous.Tests.Spyglass.Generators/SpyglassGeneratorTests.cs b/src/Experimental/test/Eventuous.Tests.Spyglass.Generators/SpyglassGeneratorTests.cs new file mode 100644 index 000000000..604e86389 --- /dev/null +++ b/src/Experimental/test/Eventuous.Tests.Spyglass.Generators/SpyglassGeneratorTests.cs @@ -0,0 +1,139 @@ +namespace Eventuous.Tests.Spyglass.Generators; + +public class SpyglassGeneratorTests { + [Test] + public async Task Should_discover_aggregate_with_state() { + const string source = """ + using Eventuous; + + public record TestState : State { + public TestState() { + On((state, _) => state); + } + } + + public class TestEvent {} + + public class TestAggregate : Aggregate { + public void DoSomething() {} + } + """; + + var compilation = CompilationHelper.CreateCompilation(source); + var (generated, diagnostics) = CompilationHelper.RunGenerator(compilation); + + await Assert.That(diagnostics).IsEmpty(); + await Assert.That(generated).IsNotNull(); + await Assert.That(generated!).Contains("\"TestAggregate\""); + await Assert.That(generated).Contains("\"TestState\""); + await Assert.That(generated).Contains("new global::TestAggregate()"); + await Assert.That(generated).Contains("aggregate.Load("); + } + + [Test] + public async Task Should_discover_standalone_state() { + const string source = """ + using Eventuous; + + public record PaymentState : State { + public PaymentState() { + On((state, _) => state); + } + } + + public class PaymentRecorded {} + """; + + var compilation = CompilationHelper.CreateCompilation(source); + var (generated, diagnostics) = CompilationHelper.RunGenerator(compilation); + + await Assert.That(diagnostics).IsEmpty(); + await Assert.That(generated).IsNotNull(); + await Assert.That(generated!).Contains("null,"); + await Assert.That(generated).Contains("\"PaymentState\""); + await Assert.That(generated).Contains("s.When(e)"); + await Assert.That(generated).DoesNotContain("aggregate.Load("); + } + + [Test] + public async Task Should_collect_public_methods_from_aggregate() { + const string source = """ + using Eventuous; + + public record SomeState : State {} + + public class SomeAggregate : Aggregate { + public void BookRoom() {} + public void CancelBooking() {} + } + """; + + var compilation = CompilationHelper.CreateCompilation(source); + var (generated, _) = CompilationHelper.RunGenerator(compilation); + + await Assert.That(generated).IsNotNull(); + await Assert.That(generated!).Contains("\"BookRoom\""); + await Assert.That(generated).Contains("\"CancelBooking\""); + } + + [Test] + public async Task Should_emit_marker_when_no_types_found() { + const string source = """ + public class Nothing {} + """; + + var compilation = CompilationHelper.CreateCompilation(source); + var (generated, diagnostics) = CompilationHelper.RunGenerator(compilation); + + await Assert.That(diagnostics).IsEmpty(); + // No SpyglassModule_ file should be generated; only the marker comment + await Assert.That(generated).IsNull(); + } + + [Test] + public async Task Should_exclude_abstract_types() { + const string source = """ + using Eventuous; + + public abstract record BaseState : State {} + + public abstract class BaseAggregate : Aggregate {} + """; + + var compilation = CompilationHelper.CreateCompilation(source); + var (generated, diagnostics) = CompilationHelper.RunGenerator(compilation); + + await Assert.That(diagnostics).IsEmpty(); + await Assert.That(generated).IsNull(); + } + + [Test] + public async Task Should_not_register_state_as_standalone_when_aggregate_exists() { + const string source = """ + using Eventuous; + + public record OrderState : State { + public OrderState() { + On((state, _) => state); + } + } + + public class OrderPlaced {} + + public class Order : Aggregate { + public void PlaceOrder() {} + } + """; + + var compilation = CompilationHelper.CreateCompilation(source); + var (generated, _) = CompilationHelper.RunGenerator(compilation); + + await Assert.That(generated).IsNotNull(); + // Should register as aggregate (not standalone) + await Assert.That(generated!).Contains("\"Order\""); + // The null standalone registration should not appear + var registrations = generated.Split("SpyglassRegistry.Register("); + // One split before the first Register + one registration = 2 parts + await Assert.That(registrations.Length).IsEqualTo(2); + } +} From f353dae63b70b8a4fc78ebe0c0de92f208913307 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Mon, 16 Feb 2026 15:49:40 +0100 Subject: [PATCH 03/13] Cleanup --- Directory.Packages.props | 1 + .../Eventuous.Shared.Generators.csproj | 9 -- .../Constants.cs | 8 +- .../Eventuous.Spyglass.Generators.csproj | 1 + .../SpyglassGenerator.cs | 84 +++++++------------ .../Eventuous.Spyglass/SpyglassRegistry.cs | 16 ++-- 6 files changed, 44 insertions(+), 75 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 26b5371b8..d9750d9c3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -24,6 +24,7 @@ + diff --git a/src/Core/gen/Eventuous.Shared.Generators/Eventuous.Shared.Generators.csproj b/src/Core/gen/Eventuous.Shared.Generators/Eventuous.Shared.Generators.csproj index b057a3a1f..ffb96459a 100644 --- a/src/Core/gen/Eventuous.Shared.Generators/Eventuous.Shared.Generators.csproj +++ b/src/Core/gen/Eventuous.Shared.Generators/Eventuous.Shared.Generators.csproj @@ -13,13 +13,4 @@ - - - - - - - - - diff --git a/src/Experimental/gen/Eventuous.Spyglass.Generators/Constants.cs b/src/Experimental/gen/Eventuous.Spyglass.Generators/Constants.cs index 143c045b0..c888d1eb2 100644 --- a/src/Experimental/gen/Eventuous.Spyglass.Generators/Constants.cs +++ b/src/Experimental/gen/Eventuous.Spyglass.Generators/Constants.cs @@ -4,8 +4,8 @@ namespace Eventuous.Spyglass.Generators; internal static class Constants { - public const string BaseNamespace = "Eventuous"; - public const string AggregateFqn = $"{BaseNamespace}.Aggregate"; - public const string StateFqn = $"{BaseNamespace}.State"; - public const string OnMethodName = "On"; + public const string BaseNamespace = "Eventuous"; + public const string AggregateFqn = $"{BaseNamespace}.Aggregate"; + public const string StateFqn = $"{BaseNamespace}.State"; + public const string OnMethodName = "On"; } diff --git a/src/Experimental/gen/Eventuous.Spyglass.Generators/Eventuous.Spyglass.Generators.csproj b/src/Experimental/gen/Eventuous.Spyglass.Generators/Eventuous.Spyglass.Generators.csproj index 26909f2d4..6a0416025 100644 --- a/src/Experimental/gen/Eventuous.Spyglass.Generators/Eventuous.Spyglass.Generators.csproj +++ b/src/Experimental/gen/Eventuous.Spyglass.Generators/Eventuous.Spyglass.Generators.csproj @@ -6,6 +6,7 @@ true + diff --git a/src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs b/src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs index e4531beb1..8ad819074 100644 --- a/src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs +++ b/src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs @@ -13,12 +13,13 @@ namespace Eventuous.Spyglass.Generators; public sealed class SpyglassGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { var data = context.CompilationProvider.Select(static (compilation, _) => { - var assemblyName = compilation.AssemblyName ?? "UnknownAssembly"; - var aggregates = DiscoverAggregates(compilation); - var states = DiscoverStandaloneStates(compilation, aggregates); + var assemblyName = compilation.AssemblyName ?? "UnknownAssembly"; + var aggregates = DiscoverAggregates(compilation); + var states = DiscoverStandaloneStates(compilation, aggregates); - return (assemblyName, aggregates, states); - }); + return (assemblyName, aggregates, states); + } + ); context.RegisterSourceOutput(data, static (spc, d) => Generate(spc, d.assemblyName, d.aggregates, d.states)); } @@ -36,9 +37,9 @@ static ImmutableArray DiscoverAggregates(Compilation compila } static ImmutableArray DiscoverStandaloneStates( - Compilation compilation, - ImmutableArray aggregates - ) { + Compilation compilation, + ImmutableArray aggregates + ) { var aggregateStateFqns = new HashSet(aggregates.Select(a => a.StateFqn)); var builder = ImmutableArray.CreateBuilder(); @@ -73,7 +74,7 @@ static void ProcessTypeForState(INamedTypeSymbol type, ImmutableArray.Builder builder) { - if (!type.IsAbstract && type.TypeKind == TypeKind.Class) { + if (type is { IsAbstract: false, TypeKind: TypeKind.Class }) { INamedTypeSymbol? stateType = null; for (var bt = type.BaseType; bt is not null; bt = bt.BaseType) { @@ -116,11 +117,9 @@ static void ProcessType(INamedTypeSymbol type, ImmutableArray() - .Where(static m => - m.MethodKind == MethodKind.Ordinary - && m.DeclaredAccessibility == Accessibility.Public - && !m.IsStatic - && !m.IsImplicitlyDeclared) + .Where(static m => m.MethodKind == MethodKind.Ordinary + && m is { DeclaredAccessibility: Accessibility.Public, IsStatic: false, IsImplicitlyDeclared: false } + ) .Select(static m => m.Name) .Distinct() .ToImmutableArray(); @@ -128,7 +127,7 @@ static void ProcessType(INamedTypeSymbol type, ImmutableArray aggregates, - ImmutableArray states - ) { + SourceProductionContext spc, + string assemblyName, + ImmutableArray aggregates, + ImmutableArray states + ) { if (aggregates.IsDefaultOrEmpty && states.IsDefaultOrEmpty) { const string marker = "// SpyglassGenerator found no aggregates or states. \n"; spc.AddSource("Spyglass.Info.g.cs", marker); @@ -239,42 +238,19 @@ static string SanitizeIdentifier(string name) { return sb.ToString(); } - readonly struct StateCandidate : IEquatable { - public readonly string StateFqn; - public readonly string StateSimpleName; - - public StateCandidate(string stateFqn, string stateSimpleName) { - StateFqn = stateFqn; - StateSimpleName = stateSimpleName; - } - - public bool Equals(StateCandidate other) => StateFqn == other.StateFqn; + readonly record struct StateCandidate(string StateFqn, string StateSimpleName); - public override bool Equals(object obj) => obj is StateCandidate other && Equals(other); - - public override int GetHashCode() => StateFqn?.GetHashCode() ?? 0; - } - - readonly struct AggregateCandidate : IEquatable { - public readonly string AggregateFqn; - public readonly string AggregateSimpleName; - public readonly string StateFqn; - public readonly string StateSimpleName; - public readonly ImmutableArray Methods; - - public AggregateCandidate(string aggregateFqn, string aggregateSimpleName, string stateFqn, string stateSimpleName, ImmutableArray methods) { - AggregateFqn = aggregateFqn; - AggregateSimpleName = aggregateSimpleName; - StateFqn = stateFqn; - StateSimpleName = stateSimpleName; - Methods = methods; - } + readonly struct AggregateCandidate(string aggregateFqn, string aggregateSimpleName, string stateFqn, string stateSimpleName, ImmutableArray methods) + : IEquatable { + public readonly string AggregateFqn = aggregateFqn; + public readonly string AggregateSimpleName = aggregateSimpleName; + public readonly string StateFqn = stateFqn; + public readonly string StateSimpleName = stateSimpleName; + public readonly ImmutableArray Methods = methods; - public bool Equals(AggregateCandidate other) - => AggregateFqn == other.AggregateFqn; + public bool Equals(AggregateCandidate other) => AggregateFqn == other.AggregateFqn; - public override bool Equals(object obj) - => obj is AggregateCandidate other && Equals(other); + public override bool Equals(object? obj) => obj is AggregateCandidate other && Equals(other); public override int GetHashCode() => AggregateFqn?.GetHashCode() ?? 0; } diff --git a/src/Experimental/src/Eventuous.Spyglass/SpyglassRegistry.cs b/src/Experimental/src/Eventuous.Spyglass/SpyglassRegistry.cs index 0fbcd476a..2c3f6bc0d 100644 --- a/src/Experimental/src/Eventuous.Spyglass/SpyglassRegistry.cs +++ b/src/Experimental/src/Eventuous.Spyglass/SpyglassRegistry.cs @@ -7,12 +7,12 @@ namespace Eventuous.Spyglass; [PublicAPI] public record SpyglassAggregateInfo( - string? AggregateType, - string StateType, - string[] Methods, - string[] Events, - SpyglassLoadDelegate LoadDelegate -); + string? AggregateType, + string StateType, + string[] Methods, + string[] Events, + SpyglassLoadDelegate LoadDelegate + ); [PublicAPI] public record SpyglassLoadResult(object State, SpyglassEventInfo[] Events); @@ -29,8 +29,8 @@ public static class SpyglassRegistry { public static SpyglassAggregateInfo[] GetAggregates() => [.. Aggregates]; public static SpyglassAggregateInfo? FindByTypeName(string typeName) - => Aggregates.FirstOrDefault(x => x.AggregateType == typeName) - ?? Aggregates.FirstOrDefault(x => StripStateSuffix(x.StateType) == typeName); + => Aggregates.FirstOrDefault(x => x.AggregateType == typeName) + ?? Aggregates.FirstOrDefault(x => StripStateSuffix(x.StateType) == typeName); static string StripStateSuffix(string s) => s.EndsWith("State") && s.Length > 5 ? s[..^5] : s; From 47c4354c7cc23dd1ab2625b21c7fa67b32566800 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Mon, 16 Feb 2026 17:32:44 +0100 Subject: [PATCH 04/13] Spyglass API tests using sample projects --- Eventuous.slnx | 1 + .../Eventuous.Tests.Spyglass.csproj | 20 ++++ .../SpyglassApiTests.cs | 107 ++++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj create mode 100644 src/Experimental/test/Eventuous.Tests.Spyglass/SpyglassApiTests.cs diff --git a/Eventuous.slnx b/Eventuous.slnx index ca70384c7..e26463fe5 100644 --- a/Eventuous.slnx +++ b/Eventuous.slnx @@ -85,6 +85,7 @@ + diff --git a/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj b/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj new file mode 100644 index 000000000..e8986da03 --- /dev/null +++ b/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj @@ -0,0 +1,20 @@ + + + net10.0 + Exe + true + + + + + + + + + + + + + + + diff --git a/src/Experimental/test/Eventuous.Tests.Spyglass/SpyglassApiTests.cs b/src/Experimental/test/Eventuous.Tests.Spyglass/SpyglassApiTests.cs new file mode 100644 index 000000000..7c4e2c2e7 --- /dev/null +++ b/src/Experimental/test/Eventuous.Tests.Spyglass/SpyglassApiTests.cs @@ -0,0 +1,107 @@ +extern alias BookingsApp; +extern alias PaymentsApp; +using System.Net; +using System.Runtime.CompilerServices; +using System.Text.Json; +using Eventuous.Spyglass; +using Eventuous.Testing; +using JetBrains.Annotations; +using Microsoft.AspNetCore.TestHost; + +namespace Eventuous.Tests.Spyglass; + +public class SpyglassApiTests { + static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + + static async Task<(WebApplication App, HttpClient Client)> CreateTestApp() { + var builder = WebApplication.CreateSlimBuilder(); + builder.Services.AddSingleton(new InMemoryEventStore()); + builder.Environment.EnvironmentName = "Development"; + builder.WebHost.UseTestServer(); + + var app = builder.Build(); + app.MapEventuousSpyglass(); + await app.StartAsync(); + + var client = app.GetTestClient(); + + return (app, client); + } + + [Test] + public async Task Ping_returns_ok() { + var (app, client) = await CreateTestApp(); + + try { + using var response = await client.GetAsync("/spyglass/ping"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var content = await response.Content.ReadAsStringAsync(); + await Assert.That(content).Contains("Okay"); + } finally { + client.Dispose(); + await app.DisposeAsync(); + } + } + + [Test] + public async Task Aggregates_contains_booking() { + // Force-load the Bookings assembly so its module initializer populates SpyglassRegistry + RuntimeHelpers.RunModuleConstructor(typeof(BookingsApp::Bookings.Registrations).Module.ModuleHandle); + + var (app, client) = await CreateTestApp(); + + try { + using var response = await client.GetAsync("/spyglass/aggregates"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var json = await response.Content.ReadAsStringAsync(); + var aggregates = JsonSerializer.Deserialize(json, JsonOptions)!; + var booking = aggregates.FirstOrDefault(a => a.StateType == "BookingState"); + + await Assert.That(booking).IsNotNull(); + await Assert.That(booking!.Type).IsEqualTo("Booking"); + await Assert.That(booking.Methods).Contains("BookRoom"); + await Assert.That(booking.Methods).Contains("RecordPayment"); + await Assert.That(booking.Events).Contains("RoomBooked"); + await Assert.That(booking.Events).Contains("PaymentRecorded"); + await Assert.That(booking.Events).Contains("BookingFullyPaid"); + } finally { + client.Dispose(); + await app.DisposeAsync(); + } + } + + [Test] + public async Task Aggregates_contains_payment_state_as_standalone() { + // Force-load the Bookings.Payments assembly so its module initializer populates SpyglassRegistry + RuntimeHelpers.RunModuleConstructor( + typeof(PaymentsApp::Bookings.Payments.Registrations).Module.ModuleHandle + ); + + var (app, client) = await CreateTestApp(); + + try { + using var response = await client.GetAsync("/spyglass/aggregates"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var json = await response.Content.ReadAsStringAsync(); + var aggregates = JsonSerializer.Deserialize(json, JsonOptions)!; + var payment = aggregates.FirstOrDefault(a => a.StateType == "PaymentState"); + + await Assert.That(payment).IsNotNull(); + await Assert.That(payment!.Type).IsNull(); + await Assert.That(payment.Methods).IsEmpty(); + await Assert.That(payment.Events).Contains("PaymentRecorded"); + } finally { + client.Dispose(); + await app.DisposeAsync(); + } + } + + [UsedImplicitly] + record AggregateEntry(string? Type, string StateType, string[] Methods, string[] Events); +} From b41d6bb6f620b6d9ddf0e0273d324ed585c10029 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Mon, 16 Feb 2026 18:37:25 +0100 Subject: [PATCH 05/13] Fix side effects --- src/Core/src/Eventuous.Domain/State.cs | 2 +- .../gen/Eventuous.Spyglass.Generators/README.md | 6 +++--- .../gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Core/src/Eventuous.Domain/State.cs b/src/Core/src/Eventuous.Domain/State.cs index c54fbf6e0..4cf5025c5 100644 --- a/src/Core/src/Eventuous.Domain/State.cs +++ b/src/Core/src/Eventuous.Domain/State.cs @@ -36,7 +36,7 @@ protected void On(Func handle) { /// /// Returns the event types that have registered handlers in this state. /// - public ICollection RegisteredEventTypes => _handlers.Keys; + public ICollection GetRegisteredEventTypes() => _handlers.Keys; readonly Dictionary> _handlers = new(); } diff --git a/src/Experimental/gen/Eventuous.Spyglass.Generators/README.md b/src/Experimental/gen/Eventuous.Spyglass.Generators/README.md index cfebf515c..620a54c9c 100644 --- a/src/Experimental/gen/Eventuous.Spyglass.Generators/README.md +++ b/src/Experimental/gen/Eventuous.Spyglass.Generators/README.md @@ -18,7 +18,7 @@ For each discovered type the generator emits a `SpyglassRegistry.Register(...)` - **Aggregate name** (or `null` for standalone states) - **State type name** - **Public methods** (aggregate commands; empty for standalone states) -- **Handled event types** — resolved by instantiating the state and reading `RegisteredEventTypes` +- **Handled event types** — resolved by instantiating the state and calling `GetRegisteredEventTypes()` - **Load delegate** — a lambda that reads events from the store and rehydrates state: - For aggregates: creates the aggregate, calls `aggregate.Load(...)` - For standalone states: folds events with `state.When(e)` @@ -31,7 +31,7 @@ SpyglassRegistry.Register(new SpyglassAggregateInfo( "Booking", "BookingState", new string[] { "BookRoom", "RecordPayment" }, - new BookingState().RegisteredEventTypes.Select(t => t.Name).ToArray(), + new BookingState().GetRegisteredEventTypes().Select(t => t.Name).ToArray(), static async (eventStore, streamName, version) => { var aggregate = new Booking(); var events = await eventStore.ReadStream(...); @@ -50,7 +50,7 @@ SpyglassRegistry.Register(new SpyglassAggregateInfo( null, "PaymentState", System.Array.Empty(), - new PaymentState().RegisteredEventTypes.Select(t => t.Name).ToArray(), + new PaymentState().GetRegisteredEventTypes().Select(t => t.Name).ToArray(), static async (eventStore, streamName, version) => { var events = await eventStore.ReadStream(...); var state = selected.Aggregate(new PaymentState(), (s, e) => s.When(e)); diff --git a/src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs b/src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs index 8ad819074..1b197ba2c 100644 --- a/src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs +++ b/src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs @@ -170,7 +170,7 @@ ImmutableArray states sb.AppendLine($" \"{Escape(c.AggregateSimpleName)}\","); sb.AppendLine($" \"{Escape(c.StateSimpleName)}\","); EmitStringArray(sb, c.Methods); - sb.AppendLine($" new {c.StateFqn}().RegisteredEventTypes.Select(t => t.Name).ToArray(),"); + sb.AppendLine($" new {c.StateFqn}().GetRegisteredEventTypes().Select(t => t.Name).ToArray(),"); sb.AppendLine(" static async (eventStore, streamName, version) => {"); sb.AppendLine($" var aggregate = new {c.AggregateFqn}();"); sb.AppendLine(" var events = await eventStore.ReadStream(new StreamName(streamName), StreamReadPosition.Start, true, default);"); @@ -190,7 +190,7 @@ ImmutableArray states sb.AppendLine(" null,"); sb.AppendLine($" \"{Escape(s.StateSimpleName)}\","); sb.AppendLine(" System.Array.Empty(),"); - sb.AppendLine($" new {s.StateFqn}().RegisteredEventTypes.Select(t => t.Name).ToArray(),"); + sb.AppendLine($" new {s.StateFqn}().GetRegisteredEventTypes().Select(t => t.Name).ToArray(),"); sb.AppendLine(" static async (eventStore, streamName, version) => {"); sb.AppendLine(" var events = await eventStore.ReadStream(new StreamName(streamName), StreamReadPosition.Start, true, default);"); sb.AppendLine(" var selected = version == -1 ? events : events.Take(version + 1).ToArray();"); From b5b0c11a1fd1b6dcf8ee0ecbb5a68cb75a0fcdf6 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Mon, 16 Feb 2026 20:43:20 +0100 Subject: [PATCH 06/13] Resolve stream names in Spyglass API --- .../Eventuous.Persistence/StreamNameMap.cs | 25 ++++++++ .../SpyglassGenerator.cs | 6 +- .../src/Eventuous.Spyglass/SpyglassApi.cs | 17 +++-- .../Eventuous.Spyglass/SpyglassRegistry.cs | 36 ++++++----- .../SpyglassGeneratorTests.cs | 2 + .../SpyglassApiTests.cs | 63 ++++++++++++++++++- 6 files changed, 120 insertions(+), 29 deletions(-) diff --git a/src/Core/src/Eventuous.Persistence/StreamNameMap.cs b/src/Core/src/Eventuous.Persistence/StreamNameMap.cs index 62ded7f6d..3bae569ac 100644 --- a/src/Core/src/Eventuous.Persistence/StreamNameMap.cs +++ b/src/Core/src/Eventuous.Persistence/StreamNameMap.cs @@ -5,15 +5,40 @@ namespace Eventuous; +/// +/// Provides a mapping mechanism for generating stream names from aggregate identifiers. +/// Allows custom stream name generation strategies to be registered for different identifier types. +/// public class StreamNameMap { readonly TypeMap> _typeMap = new(); + /// + /// Registers a custom stream name mapping function for a specific identifier type. + /// + /// The type of the identifier that inherits from . + /// A function that maps an identifier of type to a . public void Register(Func map) where TId : Id => _typeMap.Add(id => map((TId)id)); + /// + /// Gets the stream name for an aggregate with a specific identifier. + /// Uses the registered mapping function if available, otherwise falls back to the default factory. + /// + /// The type of the aggregate that inherits from . + /// The type of the aggregate state that inherits from . + /// The type of the aggregate identifier that inherits from . + /// The aggregate identifier to map to a stream name. + /// A for the specified aggregate identifier. [MethodImpl(MethodImplOptions.AggressiveInlining)] public StreamName GetStreamName(TId aggregateId) where TId : Id where T : Aggregate where TState : State, new() => _typeMap.TryGetValue(out var map) ? map(aggregateId) : StreamNameFactory.For(aggregateId); + /// + /// Gets the stream name for a specific identifier. + /// Uses the registered mapping function if available, otherwise falls back to the default factory. + /// + /// The type of the identifier that inherits from . + /// The identifier to map to a stream name. + /// A for the specified identifier. [MethodImpl(MethodImplOptions.AggressiveInlining)] public StreamName GetStreamName(TId id) where TId : Id => _typeMap.TryGetValue(out var map) ? map(id) : StreamNameFactory.For(id); } \ No newline at end of file diff --git a/src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs b/src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs index 1b197ba2c..01968455a 100644 --- a/src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs +++ b/src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs @@ -171,9 +171,10 @@ ImmutableArray states sb.AppendLine($" \"{Escape(c.StateSimpleName)}\","); EmitStringArray(sb, c.Methods); sb.AppendLine($" new {c.StateFqn}().GetRegisteredEventTypes().Select(t => t.Name).ToArray(),"); + sb.AppendLine($" static (_, entityId) => StreamName.For<{c.AggregateFqn}>(entityId),"); sb.AppendLine(" static async (eventStore, streamName, version) => {"); sb.AppendLine($" var aggregate = new {c.AggregateFqn}();"); - sb.AppendLine(" var events = await eventStore.ReadStream(new StreamName(streamName), StreamReadPosition.Start, true, default);"); + sb.AppendLine(" var events = await eventStore.ReadStream(streamName, StreamReadPosition.Start, true, default);"); sb.AppendLine(" var selected = version == -1 ? events : events.Take(version + 1).ToArray();"); sb.AppendLine(" aggregate.Load(selected.Length > 0 ? selected[^1].Revision : -1, selected.Select(x => x.Payload));"); sb.AppendLine(" return new SpyglassLoadResult("); @@ -191,8 +192,9 @@ ImmutableArray states sb.AppendLine($" \"{Escape(s.StateSimpleName)}\","); sb.AppendLine(" System.Array.Empty(),"); sb.AppendLine($" new {s.StateFqn}().GetRegisteredEventTypes().Select(t => t.Name).ToArray(),"); + sb.AppendLine($" static (_, entityId) => StreamName.ForState<{s.StateFqn}>(entityId),"); sb.AppendLine(" static async (eventStore, streamName, version) => {"); - sb.AppendLine(" var events = await eventStore.ReadStream(new StreamName(streamName), StreamReadPosition.Start, true, default);"); + sb.AppendLine(" var events = await eventStore.ReadStream(streamName, StreamReadPosition.Start, true, default);"); sb.AppendLine(" var selected = version == -1 ? events : events.Take(version + 1).ToArray();"); sb.AppendLine($" var state = selected.Select(x => x.Payload!).Aggregate(new {s.StateFqn}(), (s, e) => s.When(e));"); sb.AppendLine(" return new SpyglassLoadResult("); diff --git a/src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs b/src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs index ac60dd505..85f2899d1 100644 --- a/src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs +++ b/src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs @@ -40,10 +40,7 @@ public static IEndpointRouteBuilder MapEventuousSpyglass(this IEndpointRouteBuil builder.MapGet( "/spyglass/aggregates", - (HttpRequest request) => CheckAndReturn( - request, - () => SpyglassRegistry.GetAggregates().Select(a => new { Type = a.AggregateType, a.StateType, a.Methods, a.Events }).ToArray() - ) + (HttpRequest request) => CheckAndReturn(request, SpyglassRegistry.GetAggregates) ) .ExcludeFromDescription(); @@ -60,16 +57,16 @@ public static IEndpointRouteBuilder MapEventuousSpyglass(this IEndpointRouteBuil .ExcludeFromDescription(); builder.MapGet( - "/spyglass/load/{streamName}", - async (HttpRequest request, [FromServices] IEventStore eventStore, string streamName, [FromQuery] int version) => { + "/spyglass/load/{typeId:guid}/{entityId}", + async (HttpRequest request, [FromServices] IEventStore eventStore, [FromServices] StreamNameMap? streamNameMap, Guid typeId, string entityId, [FromQuery] int version) => { if (!Authorized(request)) return Results.Unauthorized(); - var typeName = streamName[..streamName.IndexOf('-')]; - var aggInfo = SpyglassRegistry.FindByTypeName(typeName); + var aggInfo = SpyglassRegistry.FindById(typeId); - if (aggInfo is null) return Results.NotFound($"Aggregate type '{typeName}' not found"); + if (aggInfo is null) return Results.NotFound($"No registered type found for id '{typeId}'"); - var result = await aggInfo.LoadDelegate(eventStore, streamName, version); + var streamName = aggInfo.GetStreamName(streamNameMap, entityId); + var result = await aggInfo.LoadDelegate(eventStore, streamName, version); return Results.Ok(new { result.State, Events = result.Events.Select(e => new { e.EventType, e.Payload }) }); } diff --git a/src/Experimental/src/Eventuous.Spyglass/SpyglassRegistry.cs b/src/Experimental/src/Eventuous.Spyglass/SpyglassRegistry.cs index 2c3f6bc0d..9872100c1 100644 --- a/src/Experimental/src/Eventuous.Spyglass/SpyglassRegistry.cs +++ b/src/Experimental/src/Eventuous.Spyglass/SpyglassRegistry.cs @@ -3,30 +3,38 @@ namespace Eventuous.Spyglass; -public delegate Task SpyglassLoadDelegate(IEventStore eventStore, string streamName, int version); +public delegate StreamName SpyglassGetStreamName(StreamNameMap? map, string entityId); + +public delegate Task SpyglassLoadDelegate(IEventStore eventStore, StreamName streamName, int version); -[PublicAPI] public record SpyglassAggregateInfo( - string? AggregateType, - string StateType, - string[] Methods, - string[] Events, - SpyglassLoadDelegate LoadDelegate - ); - -[PublicAPI] + string? AggregateType, + string StateType, + string[] Methods, + string[] Events, + SpyglassGetStreamName GetStreamName, + SpyglassLoadDelegate LoadDelegate + ) { + public Guid Id { get; init; } +} + +public record SpyglassAggregateEntry(Guid Id, string? AggregateType, string StateType, string[] Methods, string[] Events); + public record SpyglassLoadResult(object State, SpyglassEventInfo[] Events); -[PublicAPI] public record SpyglassEventInfo(string EventType, object? Payload); -[PublicAPI] public static class SpyglassRegistry { static readonly List Aggregates = []; - public static void Register(SpyglassAggregateInfo info) => Aggregates.Add(info); + public static void Register(SpyglassAggregateInfo info) + => Aggregates.Add(info with { Id = Guid.NewGuid() }); + + public static SpyglassAggregateEntry[] GetAggregates() + => Aggregates.Select(a => new SpyglassAggregateEntry(a.Id, a.AggregateType, a.StateType, a.Methods, a.Events)).ToArray(); - public static SpyglassAggregateInfo[] GetAggregates() => [.. Aggregates]; + public static SpyglassAggregateInfo? FindById(Guid id) + => Aggregates.FirstOrDefault(x => x.Id == id); public static SpyglassAggregateInfo? FindByTypeName(string typeName) => Aggregates.FirstOrDefault(x => x.AggregateType == typeName) diff --git a/src/Experimental/test/Eventuous.Tests.Spyglass.Generators/SpyglassGeneratorTests.cs b/src/Experimental/test/Eventuous.Tests.Spyglass.Generators/SpyglassGeneratorTests.cs index 604e86389..8f62b29ca 100644 --- a/src/Experimental/test/Eventuous.Tests.Spyglass.Generators/SpyglassGeneratorTests.cs +++ b/src/Experimental/test/Eventuous.Tests.Spyglass.Generators/SpyglassGeneratorTests.cs @@ -26,6 +26,7 @@ public void DoSomething() {} await Assert.That(generated).IsNotNull(); await Assert.That(generated!).Contains("\"TestAggregate\""); await Assert.That(generated).Contains("\"TestState\""); + await Assert.That(generated).Contains("StreamName.For(entityId)"); await Assert.That(generated).Contains("new global::TestAggregate()"); await Assert.That(generated).Contains("aggregate.Load("); } @@ -51,6 +52,7 @@ public class PaymentRecorded {} await Assert.That(generated).IsNotNull(); await Assert.That(generated!).Contains("null,"); await Assert.That(generated).Contains("\"PaymentState\""); + await Assert.That(generated).Contains("StreamName.ForState(entityId)"); await Assert.That(generated).Contains("s.When(e)"); await Assert.That(generated).DoesNotContain("aggregate.Load("); } diff --git a/src/Experimental/test/Eventuous.Tests.Spyglass/SpyglassApiTests.cs b/src/Experimental/test/Eventuous.Tests.Spyglass/SpyglassApiTests.cs index 7c4e2c2e7..5275f545c 100644 --- a/src/Experimental/test/Eventuous.Tests.Spyglass/SpyglassApiTests.cs +++ b/src/Experimental/test/Eventuous.Tests.Spyglass/SpyglassApiTests.cs @@ -7,6 +7,7 @@ using Eventuous.Testing; using JetBrains.Annotations; using Microsoft.AspNetCore.TestHost; +using static Bookings.Domain.Bookings.BookingEvents.V1; namespace Eventuous.Tests.Spyglass; @@ -62,7 +63,8 @@ public async Task Aggregates_contains_booking() { var booking = aggregates.FirstOrDefault(a => a.StateType == "BookingState"); await Assert.That(booking).IsNotNull(); - await Assert.That(booking!.Type).IsEqualTo("Booking"); + await Assert.That(booking!.Id).IsNotEqualTo(Guid.Empty); + await Assert.That(booking.AggregateType).IsEqualTo("Booking"); await Assert.That(booking.Methods).Contains("BookRoom"); await Assert.That(booking.Methods).Contains("RecordPayment"); await Assert.That(booking.Events).Contains("RoomBooked"); @@ -93,7 +95,8 @@ public async Task Aggregates_contains_payment_state_as_standalone() { var payment = aggregates.FirstOrDefault(a => a.StateType == "PaymentState"); await Assert.That(payment).IsNotNull(); - await Assert.That(payment!.Type).IsNull(); + await Assert.That(payment!.Id).IsNotEqualTo(Guid.Empty); + await Assert.That(payment.AggregateType).IsNull(); await Assert.That(payment.Methods).IsEmpty(); await Assert.That(payment.Events).Contains("PaymentRecorded"); } finally { @@ -102,6 +105,60 @@ public async Task Aggregates_contains_payment_state_as_standalone() { } } + [Test] + public async Task Load_returns_booking_state_and_events() { + RuntimeHelpers.RunModuleConstructor(typeof(BookingsApp::Bookings.Registrations).Module.ModuleHandle); + + var (app, client) = await CreateTestApp(); + + try { + var eventStore = app.Services.GetRequiredService(); + + // Get the Booking type's registry id + using var aggResponse = await client.GetAsync("/spyglass/aggregates"); + var aggJson = await aggResponse.Content.ReadAsStringAsync(); + var aggregates = JsonSerializer.Deserialize(aggJson, JsonOptions)!; + var booking = aggregates.First(a => a.AggregateType == "Booking"); + + // Write events to the in-memory store + var entityId = Guid.NewGuid().ToString(); + var streamName = new StreamName($"Booking-{entityId}"); + + await eventStore.AppendEvents( + streamName, + ExpectedStreamVersion.NoStream, + [ + new(Guid.NewGuid(), new RoomBooked("guest-1", "room-42", new(2025, 1, 1), new(2025, 1, 3), 200f, 50f, 150f, "USD", DateTimeOffset.UtcNow), new()), + new(Guid.NewGuid(), new PaymentRecorded(150f, 0f, "USD", "pay-1", "guest-1", DateTimeOffset.UtcNow), new()) + ], + default + ); + + // Call the load endpoint + using var loadResponse = await client.GetAsync($"/spyglass/load/{booking.Id}/{entityId}?version=-1"); + + await Assert.That(loadResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var loadJson = await loadResponse.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(loadJson); + var root = doc.RootElement; + + // State should reflect the loaded events + var state = root.GetProperty("state"); + await Assert.That(state.GetProperty("guestId").GetString()).IsEqualTo("guest-1"); + await Assert.That(state.GetProperty("paid").GetBoolean()).IsEqualTo(false); + + // Events should contain both events + var events = root.GetProperty("events"); + await Assert.That(events.GetArrayLength()).IsEqualTo(2); + await Assert.That(events[0].GetProperty("eventType").GetString()).IsEqualTo("RoomBooked"); + await Assert.That(events[1].GetProperty("eventType").GetString()).IsEqualTo("PaymentRecorded"); + } finally { + client.Dispose(); + await app.DisposeAsync(); + } + } + [UsedImplicitly] - record AggregateEntry(string? Type, string StateType, string[] Methods, string[] Events); + record AggregateEntry(Guid Id, string? AggregateType, string StateType, string[] Methods, string[] Events); } From bca8fd1ebf64e885b9014ecebc5a3e4afa065bc3 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Mon, 16 Feb 2026 20:55:55 +0100 Subject: [PATCH 07/13] Fix broken reference --- .../Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj b/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj index e8986da03..8eccbc1bc 100644 --- a/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj +++ b/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj @@ -10,8 +10,8 @@ - - + + From 1cbdafbaa239ec0beb431f85d72a64a0f1474566 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Mon, 16 Feb 2026 21:28:32 +0100 Subject: [PATCH 08/13] Cleaning up --- src/Core/src/Eventuous.Shared/Eventuous.Shared.csproj | 2 +- .../Eventuous.Subscriptions.csproj | 2 +- .../Eventuous.Tests.Shared.Analyzers.csproj | 2 +- src/Core/test/Eventuous.Tests/Eventuous.Tests.csproj | 2 +- src/Directory.Build.props | 1 + src/Directory.Build.targets | 8 -------- src/Directory.Testable.targets | 8 -------- src/Directory.Untestable.targets | 3 --- .../src/Eventuous.Spyglass/Eventuous.Spyglass.csproj | 2 +- .../Eventuous.Tests.Spyglass.Generators.csproj | 3 +-- .../Eventuous.Tests.Spyglass.csproj | 5 ++--- .../Eventuous.Extensions.AspNetCore.csproj | 2 +- .../Eventuous.Sut.AspNetCore.csproj | 2 +- ...Eventuous.Tests.Extensions.AspNetCore.Analyzers.csproj | 2 +- .../Eventuous.Tests.Extensions.AspNetCore.csproj | 2 +- 15 files changed, 13 insertions(+), 33 deletions(-) delete mode 100644 src/Directory.Build.targets delete mode 100644 src/Directory.Testable.targets delete mode 100644 src/Directory.Untestable.targets diff --git a/src/Core/src/Eventuous.Shared/Eventuous.Shared.csproj b/src/Core/src/Eventuous.Shared/Eventuous.Shared.csproj index e4851e9e4..20918c026 100644 --- a/src/Core/src/Eventuous.Shared/Eventuous.Shared.csproj +++ b/src/Core/src/Eventuous.Shared/Eventuous.Shared.csproj @@ -8,7 +8,7 @@ - + diff --git a/src/Core/src/Eventuous.Subscriptions/Eventuous.Subscriptions.csproj b/src/Core/src/Eventuous.Subscriptions/Eventuous.Subscriptions.csproj index 00362fc4c..798d5bba3 100644 --- a/src/Core/src/Eventuous.Subscriptions/Eventuous.Subscriptions.csproj +++ b/src/Core/src/Eventuous.Subscriptions/Eventuous.Subscriptions.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/Core/test/Eventuous.Tests.Shared.Analyzers/Eventuous.Tests.Shared.Analyzers.csproj b/src/Core/test/Eventuous.Tests.Shared.Analyzers/Eventuous.Tests.Shared.Analyzers.csproj index f0c091d68..ff991241f 100644 --- a/src/Core/test/Eventuous.Tests.Shared.Analyzers/Eventuous.Tests.Shared.Analyzers.csproj +++ b/src/Core/test/Eventuous.Tests.Shared.Analyzers/Eventuous.Tests.Shared.Analyzers.csproj @@ -14,6 +14,6 @@ - + diff --git a/src/Core/test/Eventuous.Tests/Eventuous.Tests.csproj b/src/Core/test/Eventuous.Tests/Eventuous.Tests.csproj index 31d8faec2..3c7ce76df 100644 --- a/src/Core/test/Eventuous.Tests/Eventuous.Tests.csproj +++ b/src/Core/test/Eventuous.Tests/Eventuous.Tests.csproj @@ -15,6 +15,6 @@ - + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index b06e6ebd3..5ef0d53ac 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -14,6 +14,7 @@ $(SrcRoot)\Extensions\src $(SrcRoot)\Shovel\src ..\..\src + ..\..\gen Debug;Release;Debug CI AnyCPU diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets deleted file mode 100644 index 7415f5995..000000000 --- a/src/Directory.Build.targets +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/Directory.Testable.targets b/src/Directory.Testable.targets deleted file mode 100644 index f5a153ae8..000000000 --- a/src/Directory.Testable.targets +++ /dev/null @@ -1,8 +0,0 @@ - - - - - false - CA1816 - - diff --git a/src/Directory.Untestable.targets b/src/Directory.Untestable.targets deleted file mode 100644 index 1abcb85d1..000000000 --- a/src/Directory.Untestable.targets +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/Experimental/src/Eventuous.Spyglass/Eventuous.Spyglass.csproj b/src/Experimental/src/Eventuous.Spyglass/Eventuous.Spyglass.csproj index 347e5ea6f..01e3f3518 100644 --- a/src/Experimental/src/Eventuous.Spyglass/Eventuous.Spyglass.csproj +++ b/src/Experimental/src/Eventuous.Spyglass/Eventuous.Spyglass.csproj @@ -6,6 +6,6 @@ - + diff --git a/src/Experimental/test/Eventuous.Tests.Spyglass.Generators/Eventuous.Tests.Spyglass.Generators.csproj b/src/Experimental/test/Eventuous.Tests.Spyglass.Generators/Eventuous.Tests.Spyglass.Generators.csproj index 714b9f53c..c98b45b28 100644 --- a/src/Experimental/test/Eventuous.Tests.Spyglass.Generators/Eventuous.Tests.Spyglass.Generators.csproj +++ b/src/Experimental/test/Eventuous.Tests.Spyglass.Generators/Eventuous.Tests.Spyglass.Generators.csproj @@ -1,7 +1,6 @@ Exe - true @@ -10,6 +9,6 @@ - + diff --git a/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj b/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj index 8eccbc1bc..4836e86b2 100644 --- a/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj +++ b/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj @@ -2,18 +2,17 @@ net10.0 Exe - true + true - - + diff --git a/src/Extensions/src/Eventuous.Extensions.AspNetCore/Eventuous.Extensions.AspNetCore.csproj b/src/Extensions/src/Eventuous.Extensions.AspNetCore/Eventuous.Extensions.AspNetCore.csproj index 09783ebb1..58ac30766 100644 --- a/src/Extensions/src/Eventuous.Extensions.AspNetCore/Eventuous.Extensions.AspNetCore.csproj +++ b/src/Extensions/src/Eventuous.Extensions.AspNetCore/Eventuous.Extensions.AspNetCore.csproj @@ -3,7 +3,7 @@ - + diff --git a/src/Extensions/test/Eventuous.Sut.AspNetCore/Eventuous.Sut.AspNetCore.csproj b/src/Extensions/test/Eventuous.Sut.AspNetCore/Eventuous.Sut.AspNetCore.csproj index 1e0dec22c..fc6727756 100644 --- a/src/Extensions/test/Eventuous.Sut.AspNetCore/Eventuous.Sut.AspNetCore.csproj +++ b/src/Extensions/test/Eventuous.Sut.AspNetCore/Eventuous.Sut.AspNetCore.csproj @@ -9,7 +9,7 @@ - + Analyzer false diff --git a/src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore.Analyzers/Eventuous.Tests.Extensions.AspNetCore.Analyzers.csproj b/src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore.Analyzers/Eventuous.Tests.Extensions.AspNetCore.Analyzers.csproj index 981b6aa35..d3379a761 100644 --- a/src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore.Analyzers/Eventuous.Tests.Extensions.AspNetCore.Analyzers.csproj +++ b/src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore.Analyzers/Eventuous.Tests.Extensions.AspNetCore.Analyzers.csproj @@ -11,6 +11,6 @@ - + \ No newline at end of file diff --git a/src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore/Eventuous.Tests.Extensions.AspNetCore.csproj b/src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore/Eventuous.Tests.Extensions.AspNetCore.csproj index cecd41855..9d9c39666 100644 --- a/src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore/Eventuous.Tests.Extensions.AspNetCore.csproj +++ b/src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore/Eventuous.Tests.Extensions.AspNetCore.csproj @@ -8,7 +8,7 @@ - + Analyzer false From 5876caee4586daf276dc4c71317a12f9d735131a Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Mon, 16 Feb 2026 21:42:06 +0100 Subject: [PATCH 09/13] Add more targets to samples and Spyglass tests --- samples/Directory.Build.props | 2 +- .../Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/samples/Directory.Build.props b/samples/Directory.Build.props index d99b79ebf..24ad2534f 100644 --- a/samples/Directory.Build.props +++ b/samples/Directory.Build.props @@ -1,6 +1,6 @@ - net10.0 + net8.0;net9.0;net10.0 enable enable preview diff --git a/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj b/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj index 4836e86b2..1df989f78 100644 --- a/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj +++ b/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj @@ -1,6 +1,5 @@ - net10.0 Exe true From 6fb6d0390df34a3c5e1069093e463b083abeb062 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Mon, 16 Feb 2026 21:51:26 +0100 Subject: [PATCH 10/13] Add retries to AzureDB tests --- .../test/Eventuous.Tests.Azure.ServiceBus/SendAndReceive.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Azure/test/Eventuous.Tests.Azure.ServiceBus/SendAndReceive.cs b/src/Azure/test/Eventuous.Tests.Azure.ServiceBus/SendAndReceive.cs index 7fa559f90..513c2f0cb 100644 --- a/src/Azure/test/Eventuous.Tests.Azure.ServiceBus/SendAndReceive.cs +++ b/src/Azure/test/Eventuous.Tests.Azure.ServiceBus/SendAndReceive.cs @@ -30,6 +30,7 @@ public SendAndReceive(AzureServiceBusFixture fixture, ServiceBusProducerOptions } [Test] + [Retry(3)] public async Task SingleMessage() { await _producer.Produce(_streamName, SomeEvent.Create(), _metadata, cancellationToken: TestCancellationToken); @@ -42,6 +43,7 @@ await _handler.AssertThat() } [Test] + [Retry(3)] public async Task LoadsOfMessages() { const int count = 200; From 2fc569954dc2fd53a985c4b3e3d8357096250ddc Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Mon, 16 Feb 2026 22:19:16 +0100 Subject: [PATCH 11/13] Update TestContainers --- Directory.Packages.props | 8 ++++---- samples/kurrentdb/Bookings/Program.cs | 1 - .../Eventuous.Tests.Azure.ServiceBus.csproj | 4 ++++ .../SendAndReceive.cs | 2 +- ...lementedOptimizationsValidationBenchmarks.cs | 4 ++-- .../Eventuous.Tests.Application.csproj | 6 ++++++ .../Eventuous.Tests.Shared.Analyzers.csproj | 4 ++++ .../CompositionHandlerTests.cs | 1 + .../Eventuous.Tests.Subscriptions.csproj | 4 ++++ .../test/Eventuous.Tests/Eventuous.Tests.csproj | 4 ++++ .../Eventuous.Tests.Spyglass.Generators.csproj | 4 ++++ .../Eventuous.Tests.Spyglass.csproj | 4 ++++ .../Eventuous.Tests.DependencyInjection.csproj | 4 ++++ ...Tests.Extensions.AspNetCore.Analyzers.csproj | 4 ++++ ...Eventuous.Tests.Extensions.AspNetCore.csproj | 4 ++++ .../Eventuous.Tests.Gateway.csproj | 4 ++++ .../Eventuous.Tests.GooglePubSub.csproj | 4 ++++ .../Eventuous.Tests.Kafka.csproj | 4 ++++ .../Eventuous.Tests.KurrentDB.csproj | 2 +- .../Fixtures/EsdbContainer.cs | 17 ----------------- .../Fixtures/KurrentDBContainer.cs | 17 +++++++++++++++++ .../Fixtures/StoreFixture.cs | 6 +++--- .../Metrics/MetricsFixture.cs | 6 +++--- .../Store/TieredStoreTests.cs | 4 ++-- .../Subscriptions/CustomDependenciesTests.cs | 8 ++++---- .../Fixtures/CatchUpSubscriptionFixture.cs | 6 +++--- .../Subscriptions/SubscribeTests.cs | 8 ++++---- .../Eventuous.Tests.Projections.MongoDB.csproj | 2 +- .../Fixtures/IntegrationFixture.cs | 16 ++++++++-------- .../Eventuous.Tests.Postgres.csproj | 4 ++++ .../Fixtures/PostgresContainer.cs | 2 +- .../Eventuous.Tests.RabbitMq.csproj | 4 ++++ .../Eventuous.Tests.Redis.csproj | 4 ++++ .../Eventuous.Tests.SqlServer.csproj | 4 ++++ 34 files changed, 125 insertions(+), 55 deletions(-) delete mode 100644 src/KurrentDB/test/Eventuous.Tests.KurrentDB/Fixtures/EsdbContainer.cs create mode 100644 src/KurrentDB/test/Eventuous.Tests.KurrentDB/Fixtures/KurrentDBContainer.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 40f8938ff..5a50c79b4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,10 +15,10 @@ 9.0.10 - 4.8.1 + 4.10.0 - 9.0.3 + 10.0.1 0.77.3 @@ -60,7 +60,7 @@ - + @@ -83,7 +83,7 @@ - + diff --git a/samples/kurrentdb/Bookings/Program.cs b/samples/kurrentdb/Bookings/Program.cs index 3b73caf81..ec51b0fd3 100644 --- a/samples/kurrentdb/Bookings/Program.cs +++ b/samples/kurrentdb/Bookings/Program.cs @@ -31,7 +31,6 @@ builder.Services.AddSwaggerGen(); builder.Services.AddTelemetry(); builder.Services.AddEventuous(builder.Configuration); -builder.Services.AddEventuousSpyglass(); builder.Services.Configure(options => options.SerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb)); var app = builder.Build(); diff --git a/src/Azure/test/Eventuous.Tests.Azure.ServiceBus/Eventuous.Tests.Azure.ServiceBus.csproj b/src/Azure/test/Eventuous.Tests.Azure.ServiceBus/Eventuous.Tests.Azure.ServiceBus.csproj index f00614229..dfa3e0c71 100644 --- a/src/Azure/test/Eventuous.Tests.Azure.ServiceBus/Eventuous.Tests.Azure.ServiceBus.csproj +++ b/src/Azure/test/Eventuous.Tests.Azure.ServiceBus/Eventuous.Tests.Azure.ServiceBus.csproj @@ -9,6 +9,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Azure/test/Eventuous.Tests.Azure.ServiceBus/SendAndReceive.cs b/src/Azure/test/Eventuous.Tests.Azure.ServiceBus/SendAndReceive.cs index 513c2f0cb..da8240b62 100644 --- a/src/Azure/test/Eventuous.Tests.Azure.ServiceBus/SendAndReceive.cs +++ b/src/Azure/test/Eventuous.Tests.Azure.ServiceBus/SendAndReceive.cs @@ -26,7 +26,7 @@ public SendAndReceive(AzureServiceBusFixture fixture, ServiceBusProducerOptions _metadata = new Metadata().With(MetaTags.CorrelationId, _correlationId); _serviceBusProducerOptions = producerOptions; _serviceBusSubscriptionOptions = subscriptionOptions; - this._fixture = fixture; + _fixture = fixture; } [Test] diff --git a/src/Benchmarks/Benchmarks/ImplementedOptimizationsValidationBenchmarks.cs b/src/Benchmarks/Benchmarks/ImplementedOptimizationsValidationBenchmarks.cs index b4ca16739..ac8de57d8 100644 --- a/src/Benchmarks/Benchmarks/ImplementedOptimizationsValidationBenchmarks.cs +++ b/src/Benchmarks/Benchmarks/ImplementedOptimizationsValidationBenchmarks.cs @@ -97,7 +97,7 @@ public IDisposable OldLoggingScope() { { "Stream", "TestStream" }, { "MessageType", "TestMessage" } }; - return TestLogger.BeginScope(scope); + return TestLogger.BeginScope(scope)!; } [Benchmark(Description = "NEW: Logging scope with KeyValuePair array")] @@ -107,7 +107,7 @@ public IDisposable NewLoggingScope() { new("Stream", "TestStream"), new("MessageType", "TestMessage") }; - return TestLogger.BeginScope(scope); + return TestLogger.BeginScope(scope)!; } // ===== Issue #6: CancellationTokenSource Guards ===== diff --git a/src/Core/test/Eventuous.Tests.Application/Eventuous.Tests.Application.csproj b/src/Core/test/Eventuous.Tests.Application/Eventuous.Tests.Application.csproj index 8699b6784..aabc78528 100644 --- a/src/Core/test/Eventuous.Tests.Application/Eventuous.Tests.Application.csproj +++ b/src/Core/test/Eventuous.Tests.Application/Eventuous.Tests.Application.csproj @@ -23,4 +23,10 @@ ServiceTestBase.cs + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/src/Core/test/Eventuous.Tests.Shared.Analyzers/Eventuous.Tests.Shared.Analyzers.csproj b/src/Core/test/Eventuous.Tests.Shared.Analyzers/Eventuous.Tests.Shared.Analyzers.csproj index ff991241f..339ab451a 100644 --- a/src/Core/test/Eventuous.Tests.Shared.Analyzers/Eventuous.Tests.Shared.Analyzers.csproj +++ b/src/Core/test/Eventuous.Tests.Shared.Analyzers/Eventuous.Tests.Shared.Analyzers.csproj @@ -10,6 +10,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Core/test/Eventuous.Tests.Subscriptions/CompositionHandlerTests.cs b/src/Core/test/Eventuous.Tests.Subscriptions/CompositionHandlerTests.cs index 48c3139bf..4160b4ad9 100644 --- a/src/Core/test/Eventuous.Tests.Subscriptions/CompositionHandlerTests.cs +++ b/src/Core/test/Eventuous.Tests.Subscriptions/CompositionHandlerTests.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; +#pragma warning disable CS9113 // Parameter is unread. namespace Eventuous.Tests.Subscriptions; diff --git a/src/Core/test/Eventuous.Tests.Subscriptions/Eventuous.Tests.Subscriptions.csproj b/src/Core/test/Eventuous.Tests.Subscriptions/Eventuous.Tests.Subscriptions.csproj index 0d1a9f6d7..522d87cd3 100644 --- a/src/Core/test/Eventuous.Tests.Subscriptions/Eventuous.Tests.Subscriptions.csproj +++ b/src/Core/test/Eventuous.Tests.Subscriptions/Eventuous.Tests.Subscriptions.csproj @@ -12,5 +12,9 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Core/test/Eventuous.Tests/Eventuous.Tests.csproj b/src/Core/test/Eventuous.Tests/Eventuous.Tests.csproj index 3c7ce76df..45d3d3498 100644 --- a/src/Core/test/Eventuous.Tests/Eventuous.Tests.csproj +++ b/src/Core/test/Eventuous.Tests/Eventuous.Tests.csproj @@ -11,6 +11,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Experimental/test/Eventuous.Tests.Spyglass.Generators/Eventuous.Tests.Spyglass.Generators.csproj b/src/Experimental/test/Eventuous.Tests.Spyglass.Generators/Eventuous.Tests.Spyglass.Generators.csproj index c98b45b28..fb9eda5e1 100644 --- a/src/Experimental/test/Eventuous.Tests.Spyglass.Generators/Eventuous.Tests.Spyglass.Generators.csproj +++ b/src/Experimental/test/Eventuous.Tests.Spyglass.Generators/Eventuous.Tests.Spyglass.Generators.csproj @@ -6,6 +6,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj b/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj index 1df989f78..4801b4d5e 100644 --- a/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj +++ b/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj @@ -5,6 +5,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Extensions/test/Eventuous.Tests.DependencyInjection/Eventuous.Tests.DependencyInjection.csproj b/src/Extensions/test/Eventuous.Tests.DependencyInjection/Eventuous.Tests.DependencyInjection.csproj index 6256a82e8..252054daa 100644 --- a/src/Extensions/test/Eventuous.Tests.DependencyInjection/Eventuous.Tests.DependencyInjection.csproj +++ b/src/Extensions/test/Eventuous.Tests.DependencyInjection/Eventuous.Tests.DependencyInjection.csproj @@ -10,5 +10,9 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore.Analyzers/Eventuous.Tests.Extensions.AspNetCore.Analyzers.csproj b/src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore.Analyzers/Eventuous.Tests.Extensions.AspNetCore.Analyzers.csproj index d3379a761..e741d939e 100644 --- a/src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore.Analyzers/Eventuous.Tests.Extensions.AspNetCore.Analyzers.csproj +++ b/src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore.Analyzers/Eventuous.Tests.Extensions.AspNetCore.Analyzers.csproj @@ -7,6 +7,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore/Eventuous.Tests.Extensions.AspNetCore.csproj b/src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore/Eventuous.Tests.Extensions.AspNetCore.csproj index 9d9c39666..78fe8e3c9 100644 --- a/src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore/Eventuous.Tests.Extensions.AspNetCore.csproj +++ b/src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore/Eventuous.Tests.Extensions.AspNetCore.csproj @@ -20,6 +20,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Gateway/test/Eventuous.Tests.Gateway/Eventuous.Tests.Gateway.csproj b/src/Gateway/test/Eventuous.Tests.Gateway/Eventuous.Tests.Gateway.csproj index 39e128d09..bd8a6f1ab 100644 --- a/src/Gateway/test/Eventuous.Tests.Gateway/Eventuous.Tests.Gateway.csproj +++ b/src/Gateway/test/Eventuous.Tests.Gateway/Eventuous.Tests.Gateway.csproj @@ -8,5 +8,9 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/GooglePubSub/test/Eventuous.Tests.GooglePubSub/Eventuous.Tests.GooglePubSub.csproj b/src/GooglePubSub/test/Eventuous.Tests.GooglePubSub/Eventuous.Tests.GooglePubSub.csproj index 34de9ee70..e1e46c317 100644 --- a/src/GooglePubSub/test/Eventuous.Tests.GooglePubSub/Eventuous.Tests.GooglePubSub.csproj +++ b/src/GooglePubSub/test/Eventuous.Tests.GooglePubSub/Eventuous.Tests.GooglePubSub.csproj @@ -11,5 +11,9 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Kafka/test/Eventuous.Tests.Kafka/Eventuous.Tests.Kafka.csproj b/src/Kafka/test/Eventuous.Tests.Kafka/Eventuous.Tests.Kafka.csproj index 31a7ce2e0..4b28a8866 100644 --- a/src/Kafka/test/Eventuous.Tests.Kafka/Eventuous.Tests.Kafka.csproj +++ b/src/Kafka/test/Eventuous.Tests.Kafka/Eventuous.Tests.Kafka.csproj @@ -13,5 +13,9 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Eventuous.Tests.KurrentDB.csproj b/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Eventuous.Tests.KurrentDB.csproj index e37b5ff8e..1efd1eda5 100644 --- a/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Eventuous.Tests.KurrentDB.csproj +++ b/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Eventuous.Tests.KurrentDB.csproj @@ -18,7 +18,7 @@ - + diff --git a/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Fixtures/EsdbContainer.cs b/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Fixtures/EsdbContainer.cs deleted file mode 100644 index d9251572d..000000000 --- a/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Fixtures/EsdbContainer.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Runtime.InteropServices; -using Testcontainers.EventStoreDb; - -namespace Eventuous.Tests.KurrentDB.Fixtures; - -public static class EsdbContainer { - public static EventStoreDbContainer Create() { - var image = RuntimeInformation.ProcessArchitecture == Architecture.Arm64 - ? "eventstore/eventstore:24.6.0-alpha-arm64v8" - : "eventstore/eventstore:24.6"; - - return new EventStoreDbBuilder() - .WithImage(image) - .WithEnvironment("EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP", "true") - .Build(); - } -} diff --git a/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Fixtures/KurrentDBContainer.cs b/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Fixtures/KurrentDBContainer.cs new file mode 100644 index 000000000..2a47b52ca --- /dev/null +++ b/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Fixtures/KurrentDBContainer.cs @@ -0,0 +1,17 @@ +using System.Runtime.InteropServices; +using Testcontainers.KurrentDb; + +namespace Eventuous.Tests.KurrentDB.Fixtures; + +public static class KurrentDBContainer { + public static KurrentDbContainer Create() { + var image = RuntimeInformation.ProcessArchitecture == Architecture.Arm64 + ? "kurrentplatform/kurrentdb:25.1.3-experimental-arm64-8.0-jammy" + : "kurrentplatform/kurrentdb:25.1.3"; + + return new KurrentDbBuilder() + .WithImage(image) + .WithEnvironment("KURRENTDB_ENABLE_ATOM_PUB_OVER_HTTP", "true") + .Build(); + } +} diff --git a/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Fixtures/StoreFixture.cs b/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Fixtures/StoreFixture.cs index 805e7c9e4..82493ef9d 100644 --- a/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Fixtures/StoreFixture.cs +++ b/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Fixtures/StoreFixture.cs @@ -5,11 +5,11 @@ using KurrentDB.Client; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Testcontainers.EventStoreDb; +using Testcontainers.KurrentDb; namespace Eventuous.Tests.KurrentDB.Fixtures; -public class StoreFixture : StoreFixtureBase { +public class StoreFixture : StoreFixtureBase { public KurrentDBClient Client { get; private set; } = null!; #pragma warning disable CS0618 // Type or member is obsolete public IAggregateStore AggregateStore { get; private set; } = null!; @@ -33,7 +33,7 @@ protected override void SetupServices(IServiceCollection services) { #pragma warning restore CS0618 // Type or member is obsolete } - protected override EventStoreDbContainer CreateContainer() => EsdbContainer.Create(); + protected override KurrentDbContainer CreateContainer() => KurrentDBContainer.Create(); protected override void GetDependencies(IServiceProvider provider) { Client = provider.GetRequiredService(); diff --git a/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Metrics/MetricsFixture.cs b/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Metrics/MetricsFixture.cs index 219657ace..b3bfee815 100644 --- a/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Metrics/MetricsFixture.cs +++ b/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Metrics/MetricsFixture.cs @@ -3,12 +3,12 @@ using Eventuous.KurrentDB.Subscriptions; using Eventuous.Tests.OpenTelemetry.Fixtures; using Microsoft.Extensions.DependencyInjection; -using Testcontainers.EventStoreDb; +using Testcontainers.KurrentDb; namespace Eventuous.Tests.KurrentDB.Metrics; -public class MetricsFixture : MetricsSubscriptionFixtureBase { - protected override EventStoreDbContainer CreateContainer() => EsdbContainer.Create(); +public class MetricsFixture : MetricsSubscriptionFixtureBase { + protected override KurrentDbContainer CreateContainer() => KurrentDBContainer.Create(); protected override void ConfigureSubscription(StreamSubscriptionOptions options) => options.StreamName = Stream; diff --git a/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Store/TieredStoreTests.cs b/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Store/TieredStoreTests.cs index c7618349a..e1efd488b 100644 --- a/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Store/TieredStoreTests.cs +++ b/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Store/TieredStoreTests.cs @@ -1,10 +1,10 @@ using Eventuous.Tests.Persistence.Base.Store; -using Testcontainers.EventStoreDb; +using Testcontainers.KurrentDb; namespace Eventuous.Tests.KurrentDB.Store; [ClassDataSource] -public class TieredStoreTests(StoreFixture storeFixture) : TieredStoreTestsBase(storeFixture) { +public class TieredStoreTests(StoreFixture storeFixture) : TieredStoreTestsBase(storeFixture) { [Test] public async Task Esdb_should_load_hot_and_archive() { await Should_load_hot_and_archive(); diff --git a/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/CustomDependenciesTests.cs b/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/CustomDependenciesTests.cs index f5858b3a2..2d4b0edea 100644 --- a/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/CustomDependenciesTests.cs +++ b/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/CustomDependenciesTests.cs @@ -10,7 +10,7 @@ using Eventuous.Tests.Subscriptions.Base; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Testcontainers.EventStoreDb; +using Testcontainers.KurrentDb; namespace Eventuous.Tests.KurrentDB.Subscriptions; @@ -18,14 +18,14 @@ public class CustomDependenciesTests { readonly TestSerializer _serializer = new(); readonly TestCheckpointStore _checkpointStore = new(); IHostedService _service = null!; - EventStoreDbContainer _container = null!; + KurrentDbContainer _container = null!; readonly StreamName _streamName = new($"test-{Guid.NewGuid():N}"); IProducer _producer = null!; TestEventHandler _handler = null!; [Before(Test)] public async Task Setup(CancellationToken cancellationToken) { - _container = EsdbContainer.Create(); + _container = KurrentDBContainer.Create(); var services = new ServiceCollection(); await _container.StartAsync(cancellationToken); @@ -75,7 +75,7 @@ public async Task ShouldUseCustomDependencies(CancellationToken cancellationToke } await Assert.That(_handler.Message).IsTypeOf(); - await Assert.That(_handler.Message).IsEqualTo(message with {Number = message.Number + 1}); + await Assert.That(_handler.Message).IsEqualTo(message with { Number = message.Number + 1 }); } class TestEventHandler : IEventHandler { diff --git a/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/Fixtures/CatchUpSubscriptionFixture.cs b/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/Fixtures/CatchUpSubscriptionFixture.cs index aff8f82ed..cba856877 100644 --- a/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/Fixtures/CatchUpSubscriptionFixture.cs +++ b/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/Fixtures/CatchUpSubscriptionFixture.cs @@ -3,7 +3,7 @@ using Eventuous.Tests.Subscriptions.Base; using KurrentDB.Client; using Microsoft.Extensions.DependencyInjection; -using Testcontainers.EventStoreDb; +using Testcontainers.KurrentDb; namespace Eventuous.Tests.KurrentDB.Subscriptions.Fixtures; @@ -13,11 +13,11 @@ public class CatchUpSubscriptionFixture? configureServices = null, LogLevel logLevel = LogLevel.Information - ) : SubscriptionFixtureBase(autoStart, logLevel) + ) : SubscriptionFixtureBase(autoStart, logLevel) where TSubscription : KurrentDBCatchUpSubscriptionBase where TSubscriptionOptions : CatchUpSubscriptionOptions where TEventHandler : class, IEventHandler { - protected override EventStoreDbContainer CreateContainer() => EsdbContainer.Create(); + protected override KurrentDbContainer CreateContainer() => KurrentDBContainer.Create(); protected override TestCheckpointStore GetCheckpointStore(IServiceProvider sp) => new(); diff --git a/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/SubscribeTests.cs b/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/SubscribeTests.cs index 051b625c6..014682733 100644 --- a/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/SubscribeTests.cs +++ b/src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/SubscribeTests.cs @@ -1,14 +1,14 @@ using Eventuous.KurrentDB.Subscriptions; using Eventuous.Tests.KurrentDB.Subscriptions.Fixtures; using Eventuous.Tests.Subscriptions.Base; -using Testcontainers.EventStoreDb; +using Testcontainers.KurrentDb; // ReSharper disable UnusedType.Global namespace Eventuous.Tests.KurrentDB.Subscriptions; public class SubscribeToAllFromEnd() - : SubscribeToAllBase( + : SubscribeToAllBase( new CatchUpSubscriptionFixture(opt => opt.StartFrom = InitialPosition.Latest, new("$all"), false) ) { [Test] @@ -19,7 +19,7 @@ public async Task Esdb_ShouldStartConsumptionFromEnd(CancellationToken cancellat } public class SubscribeToAll() - : SubscribeToAllBase( + : SubscribeToAllBase( new CatchUpSubscriptionFixture(_ => { }, new("$all"), false) ) { [Test] @@ -43,7 +43,7 @@ public async Task Esdb_ShouldUseExistingCheckpoint(CancellationToken cancellatio [ClassDataSource(Shared = SharedType.None)] public class SubscribeToStream(StreamNameFixture streamNameFixture) - : SubscribeToStreamBase( + : SubscribeToStreamBase( streamNameFixture.StreamName, new CatchUpSubscriptionFixture( opt => ConfigureOptions(opt, streamNameFixture), diff --git a/src/Mongo/test/Eventuous.Tests.Projections.MongoDB/Eventuous.Tests.Projections.MongoDB.csproj b/src/Mongo/test/Eventuous.Tests.Projections.MongoDB/Eventuous.Tests.Projections.MongoDB.csproj index a446b3a12..ea0cab941 100644 --- a/src/Mongo/test/Eventuous.Tests.Projections.MongoDB/Eventuous.Tests.Projections.MongoDB.csproj +++ b/src/Mongo/test/Eventuous.Tests.Projections.MongoDB/Eventuous.Tests.Projections.MongoDB.csproj @@ -20,6 +20,6 @@ - + \ No newline at end of file diff --git a/src/Mongo/test/Eventuous.Tests.Projections.MongoDB/Fixtures/IntegrationFixture.cs b/src/Mongo/test/Eventuous.Tests.Projections.MongoDB/Fixtures/IntegrationFixture.cs index 6b0c902fe..fa4c755a3 100644 --- a/src/Mongo/test/Eventuous.Tests.Projections.MongoDB/Fixtures/IntegrationFixture.cs +++ b/src/Mongo/test/Eventuous.Tests.Projections.MongoDB/Fixtures/IntegrationFixture.cs @@ -4,16 +4,16 @@ using KurrentDB.Client; using MongoDb.Bson.NodaTime; using MongoDB.Driver; -using Testcontainers.EventStoreDb; +using Testcontainers.KurrentDb; using Testcontainers.MongoDb; using TUnit.Core.Interfaces; namespace Eventuous.Tests.Projections.MongoDB.Fixtures; public sealed class IntegrationFixture : IAsyncInitializer, IAsyncDisposable { - public IEventStore EventStore { get; set; } = null!; + public IEventStore EventStore { get; set; } = null!; public KurrentDBClient Client { get; private set; } = null!; - public IMongoDatabase Mongo { get; private set; } = null!; + public IMongoDatabase Mongo { get; private set; } = null!; static IEventSerializer Serializer { get; } = new DefaultEventSerializer(TestPrimitives.DefaultOptions); @@ -30,14 +30,14 @@ static IntegrationFixture() { NodaTimeSerializers.Register(); } - EventStoreDbContainer _esdbContainer = null!; - MongoDbContainer _mongoContainer = null!; + KurrentDbContainer _esdbContainer = null!; + MongoDbContainer _mongoContainer = null!; public async Task InitializeAsync() { var image = RuntimeInformation.ProcessArchitecture == Architecture.Arm64 - ? "eventstore/eventstore:24.6.0-alpha-arm64v8" - : "eventstore/eventstore:24.6"; - _esdbContainer = new EventStoreDbBuilder().WithImage(image).Build(); + ? "kurrentplatform/kurrentdb:25.1.3-experimental-arm64-8.0-jammy" + : "kurrentplatform/kurrentdb:25.1.3"; + _esdbContainer = new KurrentDbBuilder().WithImage(image).Build(); await _esdbContainer.StartAsync(); var settings = KurrentDBClientSettings.Create(_esdbContainer.GetConnectionString()); Client = new(settings); diff --git a/src/Postgres/test/Eventuous.Tests.Postgres/Eventuous.Tests.Postgres.csproj b/src/Postgres/test/Eventuous.Tests.Postgres/Eventuous.Tests.Postgres.csproj index 5ed744442..63fcb946b 100644 --- a/src/Postgres/test/Eventuous.Tests.Postgres/Eventuous.Tests.Postgres.csproj +++ b/src/Postgres/test/Eventuous.Tests.Postgres/Eventuous.Tests.Postgres.csproj @@ -10,6 +10,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Postgres/test/Eventuous.Tests.Postgres/Fixtures/PostgresContainer.cs b/src/Postgres/test/Eventuous.Tests.Postgres/Fixtures/PostgresContainer.cs index be2b73c06..087be2dd6 100644 --- a/src/Postgres/test/Eventuous.Tests.Postgres/Fixtures/PostgresContainer.cs +++ b/src/Postgres/test/Eventuous.Tests.Postgres/Fixtures/PostgresContainer.cs @@ -4,7 +4,7 @@ namespace Eventuous.Tests.Postgres.Fixtures; public static class PostgresContainer { public static PostgreSqlContainer Create() - => new PostgreSqlBuilder() + => new PostgreSqlBuilder("postgres:14") .WithUsername("postgres") .WithPassword("secret") .WithDatabase("eventuous") diff --git a/src/RabbitMq/test/Eventuous.Tests.RabbitMq/Eventuous.Tests.RabbitMq.csproj b/src/RabbitMq/test/Eventuous.Tests.RabbitMq/Eventuous.Tests.RabbitMq.csproj index b08d4560f..996f24f08 100644 --- a/src/RabbitMq/test/Eventuous.Tests.RabbitMq/Eventuous.Tests.RabbitMq.csproj +++ b/src/RabbitMq/test/Eventuous.Tests.RabbitMq/Eventuous.Tests.RabbitMq.csproj @@ -8,5 +8,9 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Redis/test/Eventuous.Tests.Redis/Eventuous.Tests.Redis.csproj b/src/Redis/test/Eventuous.Tests.Redis/Eventuous.Tests.Redis.csproj index 2d8ef36f1..78daa2ab3 100644 --- a/src/Redis/test/Eventuous.Tests.Redis/Eventuous.Tests.Redis.csproj +++ b/src/Redis/test/Eventuous.Tests.Redis/Eventuous.Tests.Redis.csproj @@ -7,6 +7,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/SqlServer/test/Eventuous.Tests.SqlServer/Eventuous.Tests.SqlServer.csproj b/src/SqlServer/test/Eventuous.Tests.SqlServer/Eventuous.Tests.SqlServer.csproj index 48a475f41..e7780e7bb 100644 --- a/src/SqlServer/test/Eventuous.Tests.SqlServer/Eventuous.Tests.SqlServer.csproj +++ b/src/SqlServer/test/Eventuous.Tests.SqlServer/Eventuous.Tests.SqlServer.csproj @@ -8,6 +8,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + From 11affe5e104a8fb8fd99af6c45e00dec246bb056 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Mon, 16 Feb 2026 22:27:51 +0100 Subject: [PATCH 12/13] Return 404 if stream doesn't exist --- .../SpyglassGenerator.cs | 6 +++-- .../src/Eventuous.Spyglass/SpyglassApi.cs | 4 +++- .../Eventuous.Spyglass/SpyglassRegistry.cs | 2 +- ...Eventuous.Tests.Spyglass.Generators.csproj | 4 ---- .../SpyglassApiTests.cs | 23 +++++++++++++++++++ 5 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs b/src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs index 01968455a..1a9ca65cb 100644 --- a/src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs +++ b/src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs @@ -173,8 +173,9 @@ ImmutableArray states sb.AppendLine($" new {c.StateFqn}().GetRegisteredEventTypes().Select(t => t.Name).ToArray(),"); sb.AppendLine($" static (_, entityId) => StreamName.For<{c.AggregateFqn}>(entityId),"); sb.AppendLine(" static async (eventStore, streamName, version) => {"); + sb.AppendLine(" var events = await eventStore.ReadStream(streamName, StreamReadPosition.Start, false, default);"); + sb.AppendLine(" if (events.Length == 0) return null;"); sb.AppendLine($" var aggregate = new {c.AggregateFqn}();"); - sb.AppendLine(" var events = await eventStore.ReadStream(streamName, StreamReadPosition.Start, true, default);"); sb.AppendLine(" var selected = version == -1 ? events : events.Take(version + 1).ToArray();"); sb.AppendLine(" aggregate.Load(selected.Length > 0 ? selected[^1].Revision : -1, selected.Select(x => x.Payload));"); sb.AppendLine(" return new SpyglassLoadResult("); @@ -194,7 +195,8 @@ ImmutableArray states sb.AppendLine($" new {s.StateFqn}().GetRegisteredEventTypes().Select(t => t.Name).ToArray(),"); sb.AppendLine($" static (_, entityId) => StreamName.ForState<{s.StateFqn}>(entityId),"); sb.AppendLine(" static async (eventStore, streamName, version) => {"); - sb.AppendLine(" var events = await eventStore.ReadStream(streamName, StreamReadPosition.Start, true, default);"); + sb.AppendLine(" var events = await eventStore.ReadStream(streamName, StreamReadPosition.Start, false, default);"); + sb.AppendLine(" if (events.Length == 0) return null;"); sb.AppendLine(" var selected = version == -1 ? events : events.Take(version + 1).ToArray();"); sb.AppendLine($" var state = selected.Select(x => x.Payload!).Aggregate(new {s.StateFqn}(), (s, e) => s.When(e));"); sb.AppendLine(" return new SpyglassLoadResult("); diff --git a/src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs b/src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs index 85f2899d1..3951b2784 100644 --- a/src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs +++ b/src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs @@ -68,7 +68,9 @@ public static IEndpointRouteBuilder MapEventuousSpyglass(this IEndpointRouteBuil var streamName = aggInfo.GetStreamName(streamNameMap, entityId); var result = await aggInfo.LoadDelegate(eventStore, streamName, version); - return Results.Ok(new { result.State, Events = result.Events.Select(e => new { e.EventType, e.Payload }) }); + return result is null + ? Results.NotFound($"Stream '{streamName}' not found") + : Results.Ok(new { result.State, Events = result.Events.Select(e => new { e.EventType, e.Payload }) }); } ) .ExcludeFromDescription(); diff --git a/src/Experimental/src/Eventuous.Spyglass/SpyglassRegistry.cs b/src/Experimental/src/Eventuous.Spyglass/SpyglassRegistry.cs index 9872100c1..f502f861c 100644 --- a/src/Experimental/src/Eventuous.Spyglass/SpyglassRegistry.cs +++ b/src/Experimental/src/Eventuous.Spyglass/SpyglassRegistry.cs @@ -5,7 +5,7 @@ namespace Eventuous.Spyglass; public delegate StreamName SpyglassGetStreamName(StreamNameMap? map, string entityId); -public delegate Task SpyglassLoadDelegate(IEventStore eventStore, StreamName streamName, int version); +public delegate Task SpyglassLoadDelegate(IEventStore eventStore, StreamName streamName, int version); public record SpyglassAggregateInfo( string? AggregateType, diff --git a/src/Experimental/test/Eventuous.Tests.Spyglass.Generators/Eventuous.Tests.Spyglass.Generators.csproj b/src/Experimental/test/Eventuous.Tests.Spyglass.Generators/Eventuous.Tests.Spyglass.Generators.csproj index fb9eda5e1..c98b45b28 100644 --- a/src/Experimental/test/Eventuous.Tests.Spyglass.Generators/Eventuous.Tests.Spyglass.Generators.csproj +++ b/src/Experimental/test/Eventuous.Tests.Spyglass.Generators/Eventuous.Tests.Spyglass.Generators.csproj @@ -6,10 +6,6 @@ - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - diff --git a/src/Experimental/test/Eventuous.Tests.Spyglass/SpyglassApiTests.cs b/src/Experimental/test/Eventuous.Tests.Spyglass/SpyglassApiTests.cs index 5275f545c..7619e3d71 100644 --- a/src/Experimental/test/Eventuous.Tests.Spyglass/SpyglassApiTests.cs +++ b/src/Experimental/test/Eventuous.Tests.Spyglass/SpyglassApiTests.cs @@ -159,6 +159,29 @@ await eventStore.AppendEvents( } } + [Test] + public async Task Load_returns_not_found_for_nonexistent_stream() { + RuntimeHelpers.RunModuleConstructor(typeof(BookingsApp::Bookings.Registrations).Module.ModuleHandle); + + var (app, client) = await CreateTestApp(); + + try { + // Get the Booking type's registry id + using var aggResponse = await client.GetAsync("/spyglass/aggregates"); + var aggJson = await aggResponse.Content.ReadAsStringAsync(); + var aggregates = JsonSerializer.Deserialize(aggJson, JsonOptions)!; + var booking = aggregates.First(a => a.AggregateType == "Booking"); + + // Load a non-existing entity + using var loadResponse = await client.GetAsync($"/spyglass/load/{booking.Id}/does-not-exist?version=-1"); + + await Assert.That(loadResponse.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } finally { + client.Dispose(); + await app.DisposeAsync(); + } + } + [UsedImplicitly] record AggregateEntry(Guid Id, string? AggregateType, string StateType, string[] Methods, string[] Events); } From c10e47de58b34ebfbc6a8a33efcbf34bf9d64497 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Mon, 16 Feb 2026 22:40:54 +0100 Subject: [PATCH 13/13] Return v1.0 in Spyglass API ping response --- src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs | 2 +- .../test/Eventuous.Tests.Spyglass/SpyglassApiTests.cs | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs b/src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs index 3951b2784..b4bcb868a 100644 --- a/src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs +++ b/src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs @@ -35,7 +35,7 @@ public static IEndpointRouteBuilder MapEventuousSpyglass(this IEndpointRouteBuil logger.LogWarning("Spyglass API is not secured, ensure that it's not exposed to the Internet"); } - builder.MapGet("/spyglass/ping", (HttpRequest request) => CheckAndReturn(request, () => "Okay")) + builder.MapGet("/spyglass/ping", (HttpRequest request) => CheckAndReturn(request, () => "v1.0")) .ExcludeFromDescription(); builder.MapGet( diff --git a/src/Experimental/test/Eventuous.Tests.Spyglass/SpyglassApiTests.cs b/src/Experimental/test/Eventuous.Tests.Spyglass/SpyglassApiTests.cs index 7619e3d71..716d15e28 100644 --- a/src/Experimental/test/Eventuous.Tests.Spyglass/SpyglassApiTests.cs +++ b/src/Experimental/test/Eventuous.Tests.Spyglass/SpyglassApiTests.cs @@ -37,9 +37,6 @@ public async Task Ping_returns_ok() { using var response = await client.GetAsync("/spyglass/ping"); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); - - var content = await response.Content.ReadAsStringAsync(); - await Assert.That(content).Contains("Okay"); } finally { client.Dispose(); await app.DisposeAsync();