diff --git a/CLAUDE.md b/CLAUDE.md
index b86c10e..bd69415 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -107,6 +107,13 @@ Critical path for MVP: Stages 1–5, 7–8, 10–11
no longer referenced, along with `xunit.runner.visualstudio` and `coverlet.collector`.
- **Pattern**: Mirror Marten's IntegrationContext base class
- **Database**: Dockerized SQL Server 2025 on localhost:11433
+- **Never run two test runs at once.** Tests share one SQL Server instance and isolate by
+ `DatabaseSchemaName` inside `master`, not by database, so concurrent runs — a second `dotnet test`,
+ a run started before an earlier one finished, or a run left alive after you killed its parent shell
+ — step on each other's schemas and produce large, scattered, misleading failure sets across
+ unrelated areas (query plans, flat tables, partitioning, subscriptions). Let a run finish, and
+ confirm with `pgrep -f Polecat.Tests` before starting another. A killed run in particular can leave
+ its test host alive and its schemas half-torn-down; drop the leftovers before re-running.
- **Test naming**: snake_case file names (e.g., `start_stream_tests.cs`)
- **Assertions**: Shouldly (or similar fluent assertions)
- **Lifecycle**: `IAsyncLifetime` is ValueTask-based and inherits `IAsyncDisposable`. If a class
diff --git a/Directory.Packages.props b/Directory.Packages.props
index e90a1df..4a88bbd 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -62,14 +62,14 @@
re-reads progression and succeeds when the replay had already reached the mark, plus a
configurable DaemonSettings.SideEffectGateTimeout), jasperfx#595 (BatchingChannel could
deliver its trailing batch twice on shutdown) and #597. Same lockstep rule as above. -->
-
-
+
+
-
+
-
+
@@ -165,7 +165,7 @@
-
+
diff --git a/src/Polecat.Tests/Compliance/ComplianceQuerySessionAlias.cs b/src/Polecat.Tests/Compliance/ComplianceQuerySessionAlias.cs
index ec12b3d..f730b27 100644
--- a/src/Polecat.Tests/Compliance/ComplianceQuerySessionAlias.cs
+++ b/src/Polecat.Tests/Compliance/ComplianceQuerySessionAlias.cs
@@ -3,3 +3,8 @@
// by type name, so a per-consumer global alias lets one shared source file bind to Polecat's
// IQuerySession here and to Marten's in Marten.
global using ComplianceQuerySession = Polecat.IQuerySession;
+
+// Same mechanism for the EventProjection suites. Those declare projection types at file scope, so
+// they cannot reach the pair their suite class is generic over.
+global using ComplianceOperations = Polecat.IDocumentSession;
+global using ComplianceEventProjection = Polecat.Projections.EventProjection;
diff --git a/src/Polecat.Tests/Compliance/PolecatComplianceFixture.cs b/src/Polecat.Tests/Compliance/PolecatComplianceFixture.cs
index 3a85212..3a07392 100644
--- a/src/Polecat.Tests/Compliance/PolecatComplianceFixture.cs
+++ b/src/Polecat.Tests/Compliance/PolecatComplianceFixture.cs
@@ -26,12 +26,17 @@ protected override async Task BuildStoreAsync(ComplianceStoreConfig config)
var options = new StoreOptions
{
- ConnectionString = ConnectionSource.ConnectionString,
+ ConnectionString = connectionStringFor(config),
AutoCreateSchemaObjects = AutoCreate.All,
DatabaseSchemaName = schemaName,
UseNativeJsonType = ConnectionSource.SupportsNativeJson
};
+ if (config.MaxConcurrentRebuildsPerDatabase.HasValue)
+ {
+ options.DaemonSettings.MaxConcurrentRebuildsPerDatabase = config.MaxConcurrentRebuildsPerDatabase;
+ }
+
config.ApplyTo(new PolecatComplianceRegistrar(options));
_store = new DocumentStore(options);
@@ -42,6 +47,19 @@ protected override async Task BuildStoreAsync(ComplianceStoreConfig config)
await _store.Database.ApplyAllConfiguredChangesToDatabaseAsync().ConfigureAwait(false);
}
+ private static string connectionStringFor(ComplianceStoreConfig config)
+ {
+ if (!config.MaxPoolSize.HasValue)
+ {
+ return ConnectionSource.ConnectionString;
+ }
+
+ return new SqlConnectionStringBuilder(ConnectionSource.ConnectionString)
+ {
+ MaxPoolSize = config.MaxPoolSize.Value
+ }.ConnectionString;
+ }
+
public override IDocumentSession OpenSession() => _store.LightweightSession();
public override Task SaveChangesAsync(IDocumentSession session, CancellationToken token)
@@ -59,8 +77,14 @@ public override Task SaveChangesAsync(IDocumentSession session, CancellationToke
$"Polecat cannot load documents by an identity of type {id.GetType().FullName}")
};
+ public override void StoreDocument(IDocumentSession session, T document) => session.Store(document);
+
public override IEventStoreOperations EventsFor(IDocumentSession session) => session.Events;
+ public override IEventStore EventStore => _store;
+
+ public override IEnumerable AllAggregateTypes() => _store.Options.Projections.AllAggregateTypes();
+
public override IComplianceBatch CreateBatch(IQuerySession session)
=> new PolecatComplianceBatch(session.CreateBatchQuery());
@@ -130,6 +154,9 @@ public void Snapshot(SnapshotLifecycle lifecycle) where TDoc : notnull
public void LiveAggregation() where TDoc : notnull
{
}
+
+ public void AddProjection(ProjectionBase projection, ProjectionLifecycle lifecycle)
+ => _options.Projections.Add((IProjectionSource)projection, lifecycle);
}
internal class PolecatComplianceBatch : IComplianceBatch
diff --git a/src/Polecat.Tests/Compliance/polecat_event_store_compliance.cs b/src/Polecat.Tests/Compliance/polecat_event_store_compliance.cs
index ea91c0d..f4d505d 100644
--- a/src/Polecat.Tests/Compliance/polecat_event_store_compliance.cs
+++ b/src/Polecat.Tests/Compliance/polecat_event_store_compliance.cs
@@ -21,3 +21,15 @@ public class assign_tag_where_compliance
public class async_daemon_compliance
: AsyncDaemonCompliance;
+
+public class auto_discovered_aggregate_compliance
+ : AutoDiscoveredAggregateCompliance;
+
+public class event_projection_registration_compliance
+ : EventProjectionRegistrationCompliance;
+
+public class event_projection_enrichment_compliance
+ : EventProjectionEnrichmentCompliance;
+
+public class rebuild_concurrency_cap_compliance
+ : RebuildConcurrencyCapCompliance;
diff --git a/src/Polecat.Tests/Events/auto_discover_aggregate_types.cs b/src/Polecat.Tests/Events/auto_discover_aggregate_types.cs
deleted file mode 100644
index 4a01571..0000000
--- a/src/Polecat.Tests/Events/auto_discover_aggregate_types.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-using JasperFx.Events.ComplianceTests;
-using Polecat.Tests.Harness;
-
-namespace Polecat.Tests.Events;
-
-///
-/// Tests that verify self-aggregating types with source-generated evolvers
-/// are automatically discovered and registered in StoreOptions.Projections
-/// even without explicit Add<SingleStreamProjection<T, Guid>>() registration.
-///
-[Collection("integration")]
-public class auto_discover_aggregate_types : IntegrationContext
-{
- public auto_discover_aggregate_types(DefaultStoreFixture fixture) : base(fixture)
- {
- }
-
- [Fact]
- public void self_aggregating_types_are_auto_discovered()
- {
- // These types have source-generated evolvers via Evolve(IEvent)
- // but are NOT explicitly registered via Projections.Snapshot()
- var aggregateTypes = theStore.Options.Projections.AllAggregateTypes().ToArray();
-
- // MutableIEventEvolveAggregate has a generated evolver
- aggregateTypes.ShouldContain(typeof(MutableIEventEvolveAggregate));
- }
-
- [Fact]
- public async Task auto_discovered_type_works_for_live_aggregation()
- {
- // No explicit Snapshot() registration — relies on auto-discovery
- var streamId = Guid.NewGuid();
- theSession.Events.StartStream(streamId, new EvolveAEvent(), new EvolveBEvent(), new EvolveCEvent());
- await theSession.SaveChangesAsync(TestContext.Current.CancellationToken);
-
- var aggregate = await theSession.Events.AggregateStreamAsync(streamId, token: TestContext.Current.CancellationToken);
- aggregate.ShouldNotBeNull();
- aggregate.ACount.ShouldBe(1);
- aggregate.BCount.ShouldBe(1);
- aggregate.CCount.ShouldBe(1);
- }
-}
diff --git a/src/Polecat.Tests/Projections/event_projection_enrichment_tests.cs b/src/Polecat.Tests/Projections/event_projection_enrichment_tests.cs
deleted file mode 100644
index 6a89c00..0000000
--- a/src/Polecat.Tests/Projections/event_projection_enrichment_tests.cs
+++ /dev/null
@@ -1,189 +0,0 @@
-using JasperFx.Events;
-using JasperFx.Events.Projections;
-using Polecat.Projections;
-using Polecat.Tests.Harness;
-using Shouldly;
-
-namespace Polecat.Tests.Projections;
-
-public class event_projection_enrichment_tests : OneOffConfigurationsContext
-{
- [Fact]
- public async Task enrichment_sets_data_before_apply_inline()
- {
- ConfigureStore(opts =>
- {
- opts.Projections.Add(new SimpleEnrichmentProjection(), ProjectionLifecycle.Inline);
- });
- await theDatabase.ApplyAllConfiguredChangesToDatabaseAsync(ct: TestContext.Current.CancellationToken);
-
- var taskId = Guid.NewGuid();
- await using (var session = theStore.LightweightSession())
- {
- session.Events.StartStream(taskId,
- new EnrichmentTaskAssigned { TaskId = taskId, UserId = Guid.NewGuid() });
- await session.SaveChangesAsync(TestContext.Current.CancellationToken);
- }
-
- await using var query = theStore.QuerySession();
- var summary = await query.LoadAsync(taskId, TestContext.Current.CancellationToken);
- summary.ShouldNotBeNull();
- summary.AssignedUserName.ShouldBe("Enriched User");
- }
-
- [Fact]
- public async Task enrichment_is_called_before_apply()
- {
- var callOrder = new List();
- ConfigureStore(opts =>
- {
- opts.Projections.Add(
- new EnrichmentCallOrderProjection(callOrder),
- ProjectionLifecycle.Inline);
- });
- await theDatabase.ApplyAllConfiguredChangesToDatabaseAsync(ct: TestContext.Current.CancellationToken);
-
- var streamId = Guid.NewGuid();
- await using var session = theStore.LightweightSession();
- session.Events.StartStream(streamId,
- new EnrichmentTaskAssigned { TaskId = streamId, UserId = Guid.NewGuid() });
- await session.SaveChangesAsync(TestContext.Current.CancellationToken);
-
- callOrder.ShouldBe(new[] { "EnrichEventsAsync", "Apply:EnrichmentTaskAssigned" });
- }
-
- [Fact]
- public async Task enrichment_with_database_lookup_inline()
- {
- ConfigureStore(opts =>
- {
- opts.Projections.Add(new DbLookupEnrichmentProjection(), ProjectionLifecycle.Inline);
- });
- await theDatabase.ApplyAllConfiguredChangesToDatabaseAsync(ct: TestContext.Current.CancellationToken);
-
- // Pre-store a lookup document
- var userId = Guid.NewGuid();
- await using (var session = theStore.LightweightSession())
- {
- session.Store(new EnrichmentUser { Id = userId, Name = "Alice Smith" });
- await session.SaveChangesAsync(TestContext.Current.CancellationToken);
- }
-
- var taskId = Guid.NewGuid();
- await using (var session = theStore.LightweightSession())
- {
- session.Events.StartStream(taskId,
- new EnrichmentTaskAssigned { TaskId = taskId, UserId = userId });
- await session.SaveChangesAsync(TestContext.Current.CancellationToken);
- }
-
- await using var query = theStore.QuerySession();
- var summary = await query.LoadAsync(taskId, TestContext.Current.CancellationToken);
- summary.ShouldNotBeNull();
- summary.AssignedUserName.ShouldBe("Alice Smith");
- }
-}
-
-#region Test Types
-
-public class EnrichmentTaskAssigned
-{
- public Guid TaskId { get; set; }
- public Guid UserId { get; set; }
- public string? UserName { get; set; }
-}
-
-public class EnrichmentTaskSummary
-{
- public Guid Id { get; set; }
- public string? AssignedUserName { get; set; }
-}
-
-public class EnrichmentUser
-{
- public Guid Id { get; set; }
- public string Name { get; set; } = "";
-}
-
-#endregion
-
-#region Projections
-
-public partial class SimpleEnrichmentProjection : EventProjection
-{
- public void Project(EnrichmentTaskAssigned e, IDocumentSession ops)
- {
- ops.Store(new EnrichmentTaskSummary
- {
- Id = e.TaskId,
- AssignedUserName = e.UserName
- });
- }
-
- public override Task EnrichEventsAsync(IQuerySession querySession,
- IReadOnlyList events, CancellationToken cancellation)
- {
- foreach (var e in events.OfType>())
- {
- e.Data.UserName = "Enriched User";
- }
- return Task.CompletedTask;
- }
-}
-
-public partial class EnrichmentCallOrderProjection : EventProjection
-{
- private readonly List _callOrder;
-
- public EnrichmentCallOrderProjection(List callOrder)
- {
- _callOrder = callOrder;
- }
-
- public void Project(EnrichmentTaskAssigned e, IDocumentSession ops)
- {
- _callOrder.Add($"Apply:{nameof(EnrichmentTaskAssigned)}");
- }
-
- public override Task EnrichEventsAsync(IQuerySession querySession,
- IReadOnlyList events, CancellationToken cancellation)
- {
- _callOrder.Add(nameof(EnrichEventsAsync));
- return Task.CompletedTask;
- }
-}
-
-public partial class DbLookupEnrichmentProjection : EventProjection
-{
- public void Project(EnrichmentTaskAssigned e, IDocumentSession ops)
- {
- ops.Store(new EnrichmentTaskSummary
- {
- Id = e.TaskId,
- AssignedUserName = e.UserName
- });
- }
-
- public override async Task EnrichEventsAsync(IQuerySession querySession,
- IReadOnlyList events, CancellationToken cancellation)
- {
- var assigned = events.OfType>().ToArray();
- if (assigned.Length == 0) return;
-
- var userIds = assigned.Select(e => e.Data.UserId).Distinct().ToArray();
-
- foreach (var userId in userIds)
- {
- var user = await querySession.LoadAsync(userId, cancellation);
- if (user != null)
- {
- foreach (var e in assigned.Where(a => a.Data.UserId == userId))
- {
- e.Data.UserName = user.Name;
- }
- }
- }
- }
-}
-
-#endregion
diff --git a/src/Polecat.Tests/Projections/event_projection_should_register_document_types.cs b/src/Polecat.Tests/Projections/event_projection_should_register_document_types.cs
deleted file mode 100644
index 0593456..0000000
--- a/src/Polecat.Tests/Projections/event_projection_should_register_document_types.cs
+++ /dev/null
@@ -1,99 +0,0 @@
-using System;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using JasperFx.Events;
-using JasperFx.Events.Projections;
-using Polecat.Projections;
-using Polecat.Tests.Harness;
-using Shouldly;
-using Xunit;
-
-namespace Polecat.Tests.Projections;
-
-///
-/// Document type used in an EventProjection but NOT explicitly registered with Polecat.
-/// The source generator should discover this type from Store/Insert calls in ApplyAsync
-/// and register it automatically.
-/// See https://github.com/JasperFx/marten/issues/4166
-///
-public class AuditRecord
-{
- public Guid Id { get; set; }
- public Guid StreamId { get; set; }
- public string EventType { get; set; } = string.Empty;
- public DateTimeOffset Timestamp { get; set; }
-}
-
-public class AuditableEvent
-{
- public string Description { get; set; } = string.Empty;
-}
-
-///
-/// An EventProjection with an explicit ApplyAsync override that stores a document type
-/// using operations.Store<T>(). The source generator should detect this and emit a
-/// constructor that registers AuditRecord as a published type.
-/// See https://github.com/JasperFx/marten/issues/4166
-///
-public partial class AuditRecordProjection : EventProjection
-{
- public override ValueTask ApplyAsync(IDocumentSession operations, IEvent e, CancellationToken cancellation)
- {
- switch (e.Data)
- {
- case AuditableEvent:
- operations.Store(new AuditRecord
- {
- Id = Guid.NewGuid(),
- StreamId = e.StreamId,
- EventType = e.Data.GetType().Name,
- Timestamp = e.Timestamp
- });
- break;
- }
-
- return new ValueTask();
- }
-}
-
-///
-/// An EventProjection with conventional Create method that returns a document type.
-/// The source generator should register this type via the emitted constructor.
-///
-public partial class AuditRecordCreatorProjection : EventProjection
-{
- public AuditRecord Create(AuditableEvent e) => new AuditRecord
- {
- Id = Guid.NewGuid(),
- EventType = nameof(AuditableEvent)
- };
-}
-
-public class event_projection_should_register_document_types
-{
- [Fact]
- public void explicit_apply_async_projection_should_register_document_types()
- {
- // Issue #4166: Document types used in operations.Store() inside an explicit
- // ApplyAsync override should be automatically discovered and registered
- // by the source generator.
- var projection = new AuditRecordProjection();
-
- var publishedTypes = projection.PublishedTypes().ToList();
- publishedTypes.Count.ShouldBeGreaterThan(0,
- "AuditRecordProjection should have published types");
- publishedTypes.ShouldContain(typeof(AuditRecord),
- "AuditRecord should be auto-discovered from operations.Store() in ApplyAsync");
- }
-
- [Fact]
- public void conventional_create_projection_should_register_document_types()
- {
- // EventProjection with Create method should also register the return type.
- var projection = new AuditRecordCreatorProjection();
-
- projection.PublishedTypes().ShouldContain(typeof(AuditRecord),
- "AuditRecord should be auto-discovered from Create method return type");
- }
-}
diff --git a/src/Polecat.Tests/rebuild_concurrency_cap_resolution.cs b/src/Polecat.Tests/rebuild_concurrency_cap_resolution.cs
deleted file mode 100644
index d7941f2..0000000
--- a/src/Polecat.Tests/rebuild_concurrency_cap_resolution.cs
+++ /dev/null
@@ -1,82 +0,0 @@
-using JasperFx;
-using JasperFx.Events;
-using Microsoft.Data.SqlClient;
-
-namespace Polecat.Tests;
-
-///
-/// jasperfx#420 / marten#4710 companion: resolution of the per-database rebuild
-/// concurrency cap surfaced through IEventStore.MaxConcurrentRebuildsPerDatabase.
-/// Pure unit tests — the pool-size derivation parses the connection string via
-/// without opening a connection.
-///
-public class rebuild_concurrency_cap_resolution
-{
- private const string DummyConnectionString =
- "Server=localhost;Database=rebuild_cap;Integrated Security=true;TrustServerCertificate=true";
-
- private static DocumentStore buildStore(string connectionString, Action? configure = null)
- {
- var options = new StoreOptions
- {
- ConnectionString = connectionString,
- AutoCreateSchemaObjects = AutoCreate.None,
- };
- configure?.Invoke(options);
- return new DocumentStore(options);
- }
-
- [Fact]
- public void configured_value_wins_over_derived_default()
- {
- using var store = buildStore(DummyConnectionString,
- opts => opts.DaemonSettings.MaxConcurrentRebuildsPerDatabase = 3);
- ((IEventStore)store).MaxConcurrentRebuildsPerDatabase.ShouldBe(3);
- }
-
- [Fact]
- public void non_positive_configured_value_disables_the_cap()
- {
- using var store = buildStore(DummyConnectionString,
- opts => opts.DaemonSettings.MaxConcurrentRebuildsPerDatabase = 0);
- ((IEventStore)store).MaxConcurrentRebuildsPerDatabase.ShouldBeNull();
- }
-
- [Fact]
- public void derived_default_is_pool_size_over_eight_with_floor_of_one()
- {
- var connectionString = new SqlConnectionStringBuilder(DummyConnectionString)
- {
- MaxPoolSize = 64
- }.ConnectionString;
-
- using var store = buildStore(connectionString);
- ((IEventStore)store).MaxConcurrentRebuildsPerDatabase.ShouldBe(8);
- }
-
- [Fact]
- public void derived_default_floors_at_one_for_tiny_pools()
- {
- var connectionString = new SqlConnectionStringBuilder(DummyConnectionString)
- {
- MaxPoolSize = 5
- }.ConnectionString;
-
- using var store = buildStore(connectionString);
- ((IEventStore)store).MaxConcurrentRebuildsPerDatabase.ShouldBe(1);
- }
-
- [Fact]
- public async Task usage_descriptor_carries_the_effective_cap()
- {
- // jasperfx#434: CritterWatch#309's rebuild dispatcher reads the effective cap
- // off the EventStoreUsage descriptor rather than guessing.
- using var store = buildStore(DummyConnectionString,
- opts => opts.DaemonSettings.MaxConcurrentRebuildsPerDatabase = 6);
-
- var usage = await ((IEventStore)store).TryCreateUsage(CancellationToken.None);
-
- usage.ShouldNotBeNull();
- usage!.MaxConcurrentRebuildsPerDatabase.ShouldBe(6);
- }
-}