diff --git a/Directory.Packages.props b/Directory.Packages.props
index cc376200c..5a50c79b4 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -15,15 +15,16 @@
9.0.10
- 4.8.1
+ 4.10.0
- 9.0.3
+ 10.0.1
0.77.3
+
@@ -59,7 +60,7 @@
-
+
@@ -82,7 +83,7 @@
-
+
diff --git a/Eventuous.slnx b/Eventuous.slnx
index f1c5cb885..0f296de49 100644
--- a/Eventuous.slnx
+++ b/Eventuous.slnx
@@ -73,6 +73,9 @@
+
+
+
@@ -81,6 +84,8 @@
+
+
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/samples/kurrentdb/Bookings.Payments/Application/CommandService.cs b/samples/kurrentdb/Bookings.Payments/Application/CommandService.cs
index 7f913659d..4e1c94066 100644
--- a/samples/kurrentdb/Bookings.Payments/Application/CommandService.cs
+++ b/samples/kurrentdb/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/kurrentdb/Bookings.Payments/Bookings.Payments.csproj b/samples/kurrentdb/Bookings.Payments/Bookings.Payments.csproj
index 42d100cf3..ff30478df 100644
--- a/samples/kurrentdb/Bookings.Payments/Bookings.Payments.csproj
+++ b/samples/kurrentdb/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/kurrentdb/Bookings.Payments/Domain/Payment.cs b/samples/kurrentdb/Bookings.Payments/Domain/Payment.cs
index 8dfa2ba01..57be2a332 100644
--- a/samples/kurrentdb/Bookings.Payments/Domain/Payment.cs
+++ b/samples/kurrentdb/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/kurrentdb/Bookings.Payments/Program.cs b/samples/kurrentdb/Bookings.Payments/Program.cs
index 768cfad12..fd9fe2272 100644
--- a/samples/kurrentdb/Bookings.Payments/Program.cs
+++ b/samples/kurrentdb/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/kurrentdb/Bookings/Bookings.csproj b/samples/kurrentdb/Bookings/Bookings.csproj
index bb1221974..361244a4b 100644
--- a/samples/kurrentdb/Bookings/Bookings.csproj
+++ b/samples/kurrentdb/Bookings/Bookings.csproj
@@ -33,6 +33,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 7fa559f90..da8240b62 100644
--- a/src/Azure/test/Eventuous.Tests.Azure.ServiceBus/SendAndReceive.cs
+++ b/src/Azure/test/Eventuous.Tests.Azure.ServiceBus/SendAndReceive.cs
@@ -26,10 +26,11 @@ public SendAndReceive(AzureServiceBusFixture fixture, ServiceBusProducerOptions
_metadata = new Metadata().With(MetaTags.CorrelationId, _correlationId);
_serviceBusProducerOptions = producerOptions;
_serviceBusSubscriptionOptions = subscriptionOptions;
- this._fixture = fixture;
+ _fixture = fixture;
}
[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;
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/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/Core/src/Eventuous.Domain/State.cs b/src/Core/src/Eventuous.Domain/State.cs
index cce5d8b05..4cf5025c5 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 GetRegisteredEventTypes() => _handlers.Keys;
+
readonly Dictionary> _handlers = new();
}
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/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.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 f0c091d68..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,10 +10,14 @@
+
+ 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 31d8faec2..45d3d3498 100644
--- a/src/Core/test/Eventuous.Tests/Eventuous.Tests.csproj
+++ b/src/Core/test/Eventuous.Tests/Eventuous.Tests.csproj
@@ -11,10 +11,14 @@
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
-
+
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/gen/Eventuous.Spyglass.Generators/Constants.cs b/src/Experimental/gen/Eventuous.Spyglass.Generators/Constants.cs
new file mode 100644
index 000000000..c888d1eb2
--- /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..6a0416025
--- /dev/null
+++ b/src/Experimental/gen/Eventuous.Spyglass.Generators/Eventuous.Spyglass.Generators.csproj
@@ -0,0 +1,13 @@
+
+
+ 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..620a54c9c
--- /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 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)`
+
+### Example: aggregate
+
+```csharp
+// Discovered from: public class Booking : Aggregate
+SpyglassRegistry.Register(new SpyglassAggregateInfo(
+ "Booking",
+ "BookingState",
+ new string[] { "BookRoom", "RecordPayment" },
+ new BookingState().GetRegisteredEventTypes().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().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));
+ 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..1a9ca65cb
--- /dev/null
+++ b/src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs
@@ -0,0 +1,261 @@
+// 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(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 is { IsAbstract: false, 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 is { DeclaredAccessibility: Accessibility.Public, IsStatic: false, IsImplicitlyDeclared: false }
+ )
+ .Select(static m => m.Name)
+ .Distinct()
+ .ToImmutableArray();
+
+ var aggregateFqn = MakeGlobal(type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat));
+ var stateFqn = MakeGlobal(stateType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat));
+
+ builder.Add(new(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}().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 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}().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, 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(");
+ 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 record struct StateCandidate(string StateFqn, string StateSimpleName);
+
+ 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 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/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..01e3f3518 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