diff --git a/src/DaemonTests/EventProjections/Bug_5169_composite_scenario_wipe.cs b/src/DaemonTests/EventProjections/Bug_5169_composite_scenario_wipe.cs
new file mode 100644
index 0000000000..bd565b3151
--- /dev/null
+++ b/src/DaemonTests/EventProjections/Bug_5169_composite_scenario_wipe.cs
@@ -0,0 +1,108 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using JasperFx.Events;
+using Marten;
+using Marten.Events.Aggregation;
+using Marten.Testing.Harness;
+using Shouldly;
+using Xunit;
+
+namespace DaemonTests.EventProjections;
+
+///
+/// #5169 — EventProjectionScenario's up-front wipe derived its list of document types from
+/// Options.StorageTypes. A CompositeProjection never populates its own StorageTypes
+/// (its members hold theirs), so for a store whose entire read side is one composite the wipe loop
+/// iterated NOTHING: event data was deleted, the composite's read models were not, and every scenario
+/// after the first ran against the previous scenario's documents — now orphaned from any events.
+/// Silent, and the exact opposite of what the harness leads with.
+///
+public class Bug_5169_composite_scenario_wipe: OneOffConfigurationsContext
+{
+ private void configure()
+ {
+ StoreOptions(opts =>
+ {
+ opts.Projections.CompositeProjectionFor("Wipe5169", composite =>
+ {
+ // Stage 1 snapshot, stage 2 read model — the reporter's shape.
+ composite.Snapshot();
+ composite.Add(new Wipe5169OverviewProjection(), 2);
+ });
+ });
+ }
+
+ [Fact]
+ public void the_composite_reports_the_document_types_its_members_write()
+ {
+ configure();
+
+ var published = theStore.Options.Projections.All.SelectMany(x => x.PublishedTypes()).ToArray();
+
+ published.ShouldContain(typeof(Wipe5169Invoice));
+ published.ShouldContain(typeof(Wipe5169Overview));
+
+ // ...and why the wipe could not use StorageTypes: a composite holds none of its own. That list is
+ // documented as a schema-building hint, and the members are where the real values live.
+ theStore.Options.Projections.All.SelectMany(x => x.Options.StorageTypes).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task a_second_scenario_starts_from_a_clean_slate()
+ {
+ configure();
+
+ var first = Guid.NewGuid();
+
+ await theStore.Advanced.EventProjectionScenario(scenario =>
+ {
+ scenario.StartStream(first, new Wipe5169Created(1_000_000m));
+ scenario.DocumentShouldExist(first);
+ scenario.DocumentShouldExist(first);
+ });
+
+ // A second scenario touching nothing related. Before #5169 the first scenario's read models
+ // survived the wipe while its events were deleted, so this saw both.
+ var second = Guid.NewGuid();
+
+ await theStore.Advanced.EventProjectionScenario(scenario =>
+ {
+ scenario.StartStream(second, new Wipe5169Created(1m));
+ scenario.DocumentShouldExist(second);
+ scenario.DocumentShouldExist(second);
+ });
+
+ await using var query = theStore.QuerySession();
+
+ // The leak the issue is about: an unfiltered Query() inside AssertAgainstProjectedData used to
+ // see every prior scenario's rows.
+ (await query.Query().CountAsync()).ShouldBe(1);
+ (await query.Query().CountAsync()).ShouldBe(1);
+
+ (await query.LoadAsync(first)).ShouldBeNull();
+ (await query.LoadAsync(first)).ShouldBeNull();
+ }
+}
+
+public record Wipe5169Created(decimal Amount);
+
+// Self-aggregating: CompositeProjection.Snapshot() builds a SingleStreamProjection over it.
+public class Wipe5169Invoice
+{
+ public Guid Id { get; set; }
+ public decimal Amount { get; set; }
+
+ public void Apply(Wipe5169Created e) => Amount = e.Amount;
+}
+
+public class Wipe5169Overview
+{
+ public Guid Id { get; set; }
+ public decimal ClaimedAmount { get; set; }
+}
+
+public partial class Wipe5169OverviewProjection: SingleStreamProjection
+{
+ public void Apply(Wipe5169Overview overview, Wipe5169Created e) => overview.ClaimedAmount = e.Amount;
+}
diff --git a/src/Marten/Events/TestSupport/ProjectionScenario.cs b/src/Marten/Events/TestSupport/ProjectionScenario.cs
index 56d4fa46de..ea13196f2f 100644
--- a/src/Marten/Events/TestSupport/ProjectionScenario.cs
+++ b/src/Marten/Events/TestSupport/ProjectionScenario.cs
@@ -25,11 +25,20 @@ internal ProjectionScenario(DocumentStore store)
_store = store;
}
+ ///
+ /// #5169: the wipe list comes from ,
+ /// not Options.StorageTypes. A CompositeProjection never populates its own
+ /// StorageTypes — its members hold theirs — so the loop iterated nothing for a store whose read
+ /// side is a composite, and every scenario after the first ran against the previous scenario's
+ /// documents while their events had already been deleted. PublishedTypes() is the traversal that
+ /// already knows to expand a composite into its members (and is a superset of StorageTypes for
+ /// everything else), which is why Marten's own schema build-out uses it.
+ ///
protected override async Task DeleteExistingDataAsync(CancellationToken ct)
{
await _store.Advanced.Clean.DeleteAllEventDataAsync(ct).ConfigureAwait(false);
foreach (var storageType in
- _store.Options.Projections.All.SelectMany(x => x.Options.StorageTypes))
+ _store.Options.Projections.All.SelectMany(x => x.PublishedTypes()).Distinct())
{
await _store.Advanced.Clean.DeleteDocumentsByTypeAsync(storageType, ct).ConfigureAwait(false);
}