diff --git a/.github/workflows/bobcat-samples.yml b/.github/workflows/bobcat-samples.yml
new file mode 100644
index 000000000..e294bca55
--- /dev/null
+++ b/.github/workflows/bobcat-samples.yml
@@ -0,0 +1,56 @@
+name: bobcat-samples
+
+# GH-5082: the Bobcat samples (src/Samples/Bobcat) moved here from the Bobcat repository with the Critter Stack
+# vocabulary. They are not in wolverine.slnx, so no other job touches them. This one builds every sample project and
+# runs the specs of every runnable one through the Nuke build's BobcatSamples target (build/BobcatSamples.cs), which
+# is also where the reasoning lives: why building was never enough, the quarantine list and its "stale quarantine
+# fails too" rule, the database per sample, and BankAccountES's Fisher leg. `./build.sh BobcatSamples` runs the same
+# thing locally; it starts the docker-compose Postgres (5433) itself, or reuses one already listening there.
+
+on:
+ push:
+ branches: [ main ]
+ paths:
+ - 'src/**'
+ - 'build/**'
+ - 'Directory.Packages.props'
+ - 'NuGet.config'
+ - 'docker-compose.yml'
+ - '.github/workflows/bobcat-samples.yml'
+ pull_request:
+ branches: [ main ]
+ paths:
+ - 'src/**'
+ - 'build/**'
+ - 'Directory.Packages.props'
+ - 'NuGet.config'
+ - 'docker-compose.yml'
+ - '.github/workflows/bobcat-samples.yml'
+ workflow_dispatch:
+
+env:
+ # A spec host publishes its progress to a Bobcat monitor when one answers. Nothing listens here.
+ BOBCAT_MONITOR: "0"
+
+jobs:
+ samples:
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 1
+
+ - name: Setup .NET 10
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Build and run every Bobcat sample
+ run: ./build.sh BobcatSamples
+
+ - name: Stop containers
+ if: always()
+ run: docker compose down
diff --git a/Directory.Packages.props b/Directory.Packages.props
index c784a160c..cb573f8c6 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -56,14 +56,16 @@
-
+
-
-
-
+
+
+
+
+
diff --git a/build/BobcatSamples.cs b/build/BobcatSamples.cs
new file mode 100644
index 000000000..523410bd0
--- /dev/null
+++ b/build/BobcatSamples.cs
@@ -0,0 +1,172 @@
+using System.Collections.Generic;
+using System.Linq;
+using Npgsql;
+using Nuke.Common;
+using Nuke.Common.IO;
+using Nuke.Common.Tooling;
+using Nuke.Common.Tools.DotNet;
+using Serilog;
+using static Nuke.Common.Tools.DotNet.DotNetTasks;
+
+// GH-5082: the Bobcat samples (src/Samples/Bobcat), moved here from the Bobcat repository with the Critter Stack
+// vocabulary they are written against (JasperFx/bobcat#744), along with the targets that build and run them.
+// `./build.sh BobcatSamples` is what bobcat-samples.yml runs.
+//
+// The samples are not in wolverine.slnx: each has its own .slnx, and they are written as consumers write
+// applications. So Compile never touches them, and that gap once let nine of them rot undetected in Bobcat.
+// Compiling was never enough to call a sample fixed, either: OutboxDemo compiled clean and could not start
+// (Wolverine 6 stopped shipping the runtime compiler), and half the samples sat on an older Marten and died on a
+// TypeLoadException the first time they opened a session. So these targets build every sample project AND run the
+// specs of every runnable one.
+//
+// The sample specs are Microsoft.Testing.Platform hosts, so `dotnet test` runs them and one scenario is one test
+// node. They need Postgres and nothing else.
+partial class Build
+{
+ AbsolutePath BobcatSamplesDirectory => RootDirectory / "src" / "Samples" / "Bobcat";
+
+ [Parameter("Postgres the Bobcat sample specs run against, without a database name - each sample gets its own")]
+ readonly string BobcatSamplesPostgres = "Host=localhost;Port=5433;Username=postgres;Password=postgres";
+
+ // Sample projects that do not compile today, named individually rather than skipped as a group. Two rules, and
+ // the second is the one that matters:
+ //
+ // * a project outside this list that fails -> new rot, fail the build
+ // * a project inside this list that BUILDS -> the list is stale, fail the build
+ //
+ // A quarantine that only ever forgives is how a permanently-red job becomes background noise. This one has to
+ // shrink: fix a sample, and the build tells you to delete its line. Paths are relative to the repository root,
+ // e.g. "src/Samples/Bobcat/OutboxDemo/Tests/Tests.csproj".
+ static readonly string[] QuarantinedBobcatSamples =
+ [
+ ];
+
+ // Each runnable sample and the database it uses, matching the default in its own Program.cs. Separate databases
+ // rather than one shared: MeetingGroupMonolith puts its event store in a schema called `payments`, which is also
+ // the schema PaymentsMonolith uses for everything, so a shared database would have each one's between-scenario
+ // reset wiping the other's events.
+ //
+ // BankAccountES runs its Marten leg here and its Fisher leg in TestBobcatSamplesOnFisher, which is the point of
+ // that sample: the same handlers against two stores.
+ static readonly (string Sample, string Database)[] RunnableBobcatSamples =
+ [
+ ("CqrsMinimalApi", "cqrs_minimal_api"),
+ ("BookingMonolith", "booking"),
+ ("CleanArchitectureTodos", "clean_architecture_todos"),
+ ("MoreSpeakers", "more_speakers"),
+ ("PaymentsMonolith", "inflow"),
+ ("EcommerceModularMonolith", "ecommerce"),
+ ("MeetingGroupMonolith", "meeting_groups"),
+ ("OutboxDemo", "outbox_demo"),
+ ("BankAccountES", "bank_account"),
+ ("EcommerceMicroservices", "ecommerce_catalog"),
+ ("ProjectManagement", "project_management"),
+ ];
+
+ Target CompileBobcatSamples => _ => _
+ .Executes(() =>
+ {
+ var regressions = new List();
+ var recovered = new List();
+
+ foreach (var project in BobcatSamplesDirectory.GlobFiles("**/*.csproj").OrderBy(p => p.ToString()))
+ {
+ var name = RootDirectory.GetRelativePathTo(project).ToString().Replace('\\', '/');
+ var quarantined = QuarantinedBobcatSamples.Contains(name);
+
+ if (tryRunSample(() => DotNetBuild(s => s.SetProjectFile(project).SetConfiguration(Configuration))))
+ {
+ if (quarantined)
+ {
+ Log.Error("{Project} builds now - remove it from QuarantinedBobcatSamples", name);
+ recovered.Add(name);
+ }
+ }
+ else if (quarantined)
+ {
+ Log.Warning("known broken (quarantined): {Project}", name);
+ }
+ else
+ {
+ Log.Error("{Project} failed to build", name);
+ regressions.Add(name);
+ }
+ }
+
+ Log.Information("quarantined: {Count}", QuarantinedBobcatSamples.Length);
+ Assert.Empty(regressions, $"Sample(s) newly broken: {string.Join(", ", regressions)}");
+ Assert.Empty(recovered, $"Quarantined sample(s) now build - shrink the list: {string.Join(", ", recovered)}");
+ });
+
+ Target TestBobcatSamples => _ => _
+ .DependsOn(CompileBobcatSamples)
+ .Executes(() =>
+ {
+ // Reused when something already answers on its port (another worktree's stack, a CI service).
+ StartDockerServices("postgresql");
+
+ // Created up front because a CI service container only ever makes one database. Tolerates one that
+ // already exists, so a local rerun works.
+ using (var connection = new NpgsqlConnection($"{BobcatSamplesPostgres};Database=postgres"))
+ {
+ connection.Open();
+ foreach (var (_, database) in RunnableBobcatSamples)
+ {
+ using var exists = new NpgsqlCommand("select 1 from pg_database where datname = @name", connection);
+ exists.Parameters.AddWithValue("name", database);
+ if (exists.ExecuteScalar() != null) continue;
+
+ using var create = new NpgsqlCommand($"create database \"{database}\"", connection);
+ create.ExecuteNonQuery();
+ }
+ }
+
+ var failed = new List();
+ foreach (var (sample, database) in RunnableBobcatSamples)
+ {
+ Log.Information("Running the {Sample} specs", sample);
+ var passed = tryRunSample(() => DotNetTest(s => s
+ .SetProcessAdditionalArguments("--project", BobcatSamplesDirectory / sample / "Tests")
+ .SetConfiguration(Configuration)
+ .SetProcessEnvironmentVariable("ConnectionStrings__Marten", $"{BobcatSamplesPostgres};Database={database}")));
+
+ if (!passed) failed.Add(sample);
+ }
+
+ Assert.Empty(failed, $"Sample(s) failed their specs: {string.Join(", ", failed)}");
+ });
+
+ // No services at all - a SQLite file is the whole store, which is the argument for Fisher as the inner-loop
+ // store, and a sample whose point is "the same handlers run on two stores" is only proved by running both.
+ Target TestBobcatSamplesOnFisher => _ => _
+ .DependsOn(CompileBobcatSamples)
+ .After(TestBobcatSamples)
+ .Executes(() =>
+ {
+ var database = TemporaryDirectory / "bank_account.db";
+ database.DeleteFile();
+
+ DotNetTest(s => s
+ .SetProcessAdditionalArguments("--project", BobcatSamplesDirectory / "BankAccountES" / "Tests")
+ .SetConfiguration(Configuration)
+ .SetProcessEnvironmentVariable("EventStore", "Fisher")
+ .SetProcessEnvironmentVariable("ConnectionStrings__Fisher", $"Data Source={database}"));
+ });
+
+ // What bobcat-samples.yml runs.
+ Target BobcatSamples => _ => _
+ .DependsOn(CompileBobcatSamples, TestBobcatSamples, TestBobcatSamplesOnFisher);
+
+ static bool tryRunSample(System.Action action)
+ {
+ try
+ {
+ action();
+ return true;
+ }
+ catch (ProcessException)
+ {
+ return false;
+ }
+ }
+}
diff --git a/build/CITargets.cs b/build/CITargets.cs
index e07d12362..af65edd6b 100644
--- a/build/CITargets.cs
+++ b/build/CITargets.cs
@@ -1045,6 +1045,11 @@ void runAwsShard(AbsolutePath project, Func testFilter = null)
{
var tests = RootDirectory / "src" / "Bobcat" / "Wolverine.Bobcat.Tests" / "Wolverine.Bobcat.Tests.csproj";
var httpTests = RootDirectory / "src" / "Bobcat" / "Wolverine.Bobcat.Http.Tests" / "Wolverine.Bobcat.Http.Tests.csproj";
+ // GH-5082: the store-only Critter Stack vocabulary moved here from Bobcat core, with its tests (Marten on
+ // Postgres, Fisher on SQLite). [PostgresFact] never skips with CI=true, so a missing database fails.
+ var storeOnlyTests = RootDirectory / "src" / "Bobcat" / "Bobcat.CritterStackScenario.Tests" / "Bobcat.CritterStackScenario.Tests.csproj";
+ // GH-5082: the spec writer (write-specs), which moved here from bobcat import-event-model --specs
+ var specWriterTests = RootDirectory / "src" / "Bobcat" / "Wolverine.Bobcat.EventModel.Tests" / "Wolverine.Bobcat.EventModel.Tests.csproj";
// The samples dogfooding the library. Neither ran in any CI lane before, which is how both
// sat unable to start a host (no runtime compiler since GH-2876, and the test assembly
@@ -1053,14 +1058,14 @@ void runAwsShard(AbsolutePath project, Func testFilter = null)
var processManager = RootDirectory / "src" / "Samples" / "ProcessManagerViaHandlers" /
"ProcessManagerViaHandlers.Tests" / "ProcessManagerViaHandlers.Tests.csproj";
- BuildTestProjects(tests, httpTests, incidents, processManager);
+ BuildTestProjects(tests, httpTests, storeOnlyTests, specWriterTests, incidents, processManager);
// The store-agnostic specs (GH-4892, GH-4920, GH-4921) run on Marten, Polecat AND Fisher, and
// Polecat needs SQL Server. Without it every Polecat fixture fails to start, which surfaced as a
// NullReferenceException in DisposeAsync rather than as the connection failure it was.
StartDockerServices("postgresql", "sqlserver");
- RunTestProjects([tests, httpTests, incidents, processManager]);
+ RunTestProjects([tests, httpTests, storeOnlyTests, specWriterTests, incidents, processManager]);
});
Target CIKafka => _ => _
diff --git a/build/build.cs b/build/build.cs
index bc44aeb83..c7225e5d2 100644
--- a/build/build.cs
+++ b/build/build.cs
@@ -370,7 +370,8 @@ partial class Build : NukeBuild
Solution.Persistence.Fisher.Wolverine_Fisher,
Solution.Persistence.InMemory.Wolverine_InMemory,
Solution.Bobcat.Wolverine_Bobcat,
- Solution.Bobcat.Wolverine_Bobcat_Http
+ Solution.Bobcat.Wolverine_Bobcat_Http,
+ Solution.Bobcat.Wolverine_Bobcat_EventModel
};
// GH-3905: a project can declare a and still never ship, because the Pack list above
diff --git a/docs/guide/testing.md b/docs/guide/testing.md
index e60ff97d3..2737f31bb 100644
--- a/docs/guide/testing.md
+++ b/docs/guide/testing.md
@@ -651,8 +651,9 @@ A few things to know:
### More WolverineFx.Bobcat: several stores, context, and foreign events
-`WolverineSpec` and `WolverineScenario` inherit everything Bobcat's `CritterStackSpec` does, and a few of those steps
-are worth knowing about from the Wolverine side:
+`WolverineSpec` and `WolverineScenario` inherit everything the store-only `CritterStackSpec` does (it ships in WolverineFx.Bobcat
+too, under the same `Bobcat.CritterStack` namespace, since it moved out of Bobcat core), and a few of those steps are worth
+knowing about from the Wolverine side:
* **Several event stores.** On a host with a main store and an ancillary one, such as Marten's
`AddMartenStore()`, each Given and Then goes to the store whose registrations claim its type. When the
diff --git a/docs/tutorials/event-modeling.md b/docs/tutorials/event-modeling.md
index 5368708a1..7c4411f90 100644
--- a/docs/tutorials/event-modeling.md
+++ b/docs/tutorials/event-modeling.md
@@ -264,14 +264,13 @@ dotnet tool install -g Bobcat.Console
```
Then point it at the board export and the application project. For CritterCrush, we only want two of the board's
-chapters, and we want the specifications too:
+chapters:
```bash
bobcat import-event-model K9CRUSH.emlang.v3.yaml \
--model CritterCrush --namespace CritterCrush \
--out ./CritterCrush \
- --chapter BookingAppointments VolunteeringAndHomeChecks \
- --specs --store marten
+ --chapter BookingAppointments VolunteeringAndHomeChecks
```
::: warning
@@ -363,8 +362,23 @@ settles it. That's what makes the import usable from an AI agent or from Stoat.
## Generated Specifications
-With `--specs`, the import also writes one [WolverineFx.Bobcat](https://bobcat.jasperfx.net/integrations/wolverine)
-specification per example on the board into a sibling `CritterCrush.Specs` project, with a folder per chapter. The
+Wolverine's `write-specs` command writes one [WolverineFx.Bobcat](https://bobcat.jasperfx.net/integrations/wolverine)
+specification per example on the board into a sibling `CritterCrush.Specs` project, with a folder per chapter, and the
+spec project itself when there is none yet. It comes with the `WolverineFx.Bobcat.EventModel` package (it was
+`bobcat import-event-model --specs` before the spec writer moved to Wolverine). Reference that package from the
+application, then run it with the same board and the same flags you gave the import:
+
+```bash
+dotnet run --project ./CritterCrush -- write-specs K9CRUSH.emlang.v3.yaml \
+ --model CritterCrush --namespace CritterCrush \
+ --out ./CritterCrush \
+ --chapter BookingAppointments VolunteeringAndHomeChecks \
+ --store marten
+```
+
+A specification can name a member the board only gives in an example, so the command merges those members into the
+stubs the import wrote, the same additive way a re-import does. Running it again adds the specifications for the
+board's new examples and changes nothing else. The
`AppointmentConfirmed` example from the board becomes this:
```csharp
diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/AncillaryStoreHosts.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/AncillaryStoreHosts.cs
new file mode 100644
index 000000000..de46e68cf
--- /dev/null
+++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/AncillaryStoreHosts.cs
@@ -0,0 +1,153 @@
+using Fisher;
+using JasperFx;
+using JasperFx.Events.Daemon;
+using JasperFx.Events.Projections;
+using Marten;
+using Microsoft.Extensions.Hosting;
+
+namespace Bobcat.CritterStack.ScenarioTests;
+
+// bobcat#504: an application with a main event store and an ancillary one, on Marten and on Fisher. The main
+// store holds the Wallet side of the domain, the ancillary store the Invoice side, and both register SharedNote
+// so a step about it is ambiguous.
+
+/// Marten's ancillary store marker, as AddMartenStore<IInvoicingStore>() registers it.
+public interface IInvoicingStore : Marten.IDocumentStore;
+
+/// Fisher's ancillary store marker, as AddFisherStore<IFisherInvoicingStore>() registers it.
+public interface IFisherInvoicingStore : Fisher.IDocumentStore;
+
+/// A host with two event stores, whichever store engine.
+public abstract class TwoStoreHost : StoreHost
+{
+ /// The ancillary store's IEventStore.Identity name: its marker's type name, as the store folds it.
+ public abstract string AncillaryName { get; }
+}
+
+public sealed class MartenTwoStoreHost : TwoStoreHost
+{
+ private static int _hosts;
+ private readonly string _schema = $"bobcat_cs_two_{Interlocked.Increment(ref _hosts)}";
+
+ public override string AncillaryName => "iinvoicingstore";
+
+ public override bool IsAvailable => PostgresEnvironment.IsCi || PostgresEnvironment.IsAvailable;
+
+ public override string SkipReason => PostgresEnvironment.SkipReason;
+
+ protected override IHost Build()
+ {
+ var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder();
+ builder.Services.AddMarten(opts =>
+ {
+ opts.Connection(PostgresEnvironment.ConnectionString);
+ opts.DatabaseSchemaName = _schema;
+ opts.AutoCreateSchemaObjects = AutoCreate.All;
+ opts.DisableNpgsqlLogging = true;
+
+ opts.Projections.Snapshot(SnapshotLifecycle.Inline);
+ opts.Projections.Snapshot(SnapshotLifecycle.Async);
+ opts.Schema.For();
+ })
+ .ApplyAllDatabaseChangesOnStartup()
+ .AddAsyncDaemon(DaemonMode.Solo);
+
+ builder.Services.AddMartenStore(opts =>
+ {
+ opts.Connection(PostgresEnvironment.ConnectionString);
+ opts.DatabaseSchemaName = _schema + "_inv";
+ opts.AutoCreateSchemaObjects = AutoCreate.All;
+ opts.DisableNpgsqlLogging = true;
+
+ opts.Projections.Snapshot(SnapshotLifecycle.Inline);
+ opts.Projections.Snapshot(SnapshotLifecycle.Async);
+ opts.Schema.For();
+ })
+ .ApplyAllDatabaseChangesOnStartup()
+ .AddAsyncDaemon(DaemonMode.Solo);
+
+ return builder.Build();
+ }
+
+ protected override async Task BeforeStartAsync()
+ {
+ await MartenDdl.Gate.WaitAsync();
+ await dropSchemasAsync();
+ }
+
+ protected override void AfterStartAttempt() => MartenDdl.Gate.Release();
+
+ public override async ValueTask DisposeAsync()
+ {
+ await base.DisposeAsync();
+ if (IsAvailable) await dropSchemasAsync();
+ }
+
+ private async Task dropSchemasAsync()
+ {
+ await using var connection = new Npgsql.NpgsqlConnection(PostgresEnvironment.ConnectionString);
+ await connection.OpenAsync();
+ await using var command = connection.CreateCommand();
+ command.CommandText = $"DROP SCHEMA IF EXISTS {_schema} CASCADE; DROP SCHEMA IF EXISTS {_schema}_inv CASCADE";
+ await command.ExecuteNonQueryAsync();
+ }
+}
+
+public sealed class FisherTwoStoreHost : TwoStoreHost
+{
+ private readonly string _main = Path.Combine(Path.GetTempPath(), $"bobcat-two-main-{Guid.NewGuid():N}.db");
+ private readonly string _invoicing = Path.Combine(Path.GetTempPath(), $"bobcat-two-invoicing-{Guid.NewGuid():N}.db");
+
+ public override string AncillaryName => "ifisherinvoicingstore";
+
+ protected override IHost Build()
+ {
+ var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder();
+ builder.Services.AddFisher(opts =>
+ {
+ opts.Connection($"Data Source={_main}");
+
+ opts.Projections.Snapshot(SnapshotLifecycle.Inline);
+ opts.Projections.Snapshot(SnapshotLifecycle.Async);
+ opts.Schema.For();
+ })
+ .ApplyAllDatabaseChangesOnStartup()
+ .AddAsyncDaemon(DaemonMode.Solo);
+
+ builder.Services.AddFisherStore(opts =>
+ {
+ opts.Connection($"Data Source={_invoicing}");
+
+ opts.Projections.Snapshot(SnapshotLifecycle.Inline);
+ opts.Projections.Snapshot(SnapshotLifecycle.Async);
+ opts.Schema.For();
+ })
+ .ApplyAllDatabaseChangesOnStartup()
+ .AddAsyncDaemon(DaemonMode.Solo);
+
+ return builder.Build();
+ }
+
+ public override async ValueTask DisposeAsync()
+ {
+ await base.DisposeAsync();
+
+ foreach (var file in new[] { _main, _invoicing }.SelectMany(x => new[] { x, x + "-wal", x + "-shm" }))
+ {
+ try
+ {
+ File.Delete(file);
+ }
+ catch (IOException)
+ {
+ // A temp file; the OS reclaims it
+ }
+ }
+ }
+}
+
+/// One Marten host building its schema at a time, across every host type: Postgres races on its own catalog.
+internal static class MartenDdl
+{
+ public static readonly SemaphoreSlim Gate = new(1, 1);
+}
diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/Bobcat.CritterStackScenario.Tests.csproj b/src/Bobcat/Bobcat.CritterStackScenario.Tests/Bobcat.CritterStackScenario.Tests.csproj
new file mode 100644
index 000000000..d3ba00276
--- /dev/null
+++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/Bobcat.CritterStackScenario.Tests.csproj
@@ -0,0 +1,62 @@
+
+
+
+
+ Exe
+ false
+ true
+ net9.0;net10.0
+ false
+
+ 13
+
+ $(NoWarn);NU1202;NETSDK1005;CS0419;xUnit1051;VSTHRD110;CS8981
+
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Servers.cs
+
+
+
+
diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/ConfiguredShardWaitTests.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/ConfiguredShardWaitTests.cs
new file mode 100644
index 000000000..b0ff000ad
--- /dev/null
+++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/ConfiguredShardWaitTests.cs
@@ -0,0 +1,83 @@
+using JasperFx.Events;
+using JasperFx.Events.Projections;
+using JasperFx.Events.Subscriptions;
+using NSubstitute;
+using Shouldly;
+
+namespace Bobcat.CritterStack.ScenarioTests;
+
+///
+/// A projection wait decides WHAT to wait on from the store's configured shards
+/// (IEventStore<,>.AllShards()) rather than from the progress table, because a daemon
+/// writes a shard's progress row only after its first batch — right after the first append an empty
+/// table looks exactly like "no async projections", and a wait keyed on it passes vacuously. That
+/// was seen for real against Marten (see ).
+///
+public class ConfiguredShardWaitTests
+{
+ public class Account;
+
+ [Fact]
+ public async Task an_empty_progress_table_is_not_mistaken_for_no_projections()
+ {
+ var (store, _) = storeWithShards(progress: [], "Account:All");
+
+ var ex = await Should.ThrowAsync(
+ () => EventStores.WaitForProjectionAsync(store, minSequence: 3, timeout: TimeSpan.FromMilliseconds(150)));
+
+ ex.Message.ShouldContain("'Account:All' at 0");
+ }
+
+ [Fact]
+ public async Task no_configured_shards_means_nothing_to_wait_for()
+ {
+ var (store, database) = storeWithShards(progress: []);
+
+ await EventStores.WaitForProjectionAsync(store, minSequence: 99, timeout: TimeSpan.FromMilliseconds(150));
+
+ await database.DidNotReceive().AllProjectionProgress(Arg.Any());
+ }
+
+ [Fact]
+ public async Task a_name_matching_no_configured_shard_is_a_wiring_error()
+ {
+ var (store, _) = storeWithShards(progress: [], "Orders:All", "Billing:All");
+
+ var ex = await Should.ThrowAsync(
+ () => EventStores.WaitForProjectionAsync(store, minSequence: 1, timeout: TimeSpan.FromMilliseconds(150)));
+
+ ex.Message.ShouldContain("'Orders:All'");
+ ex.Message.ShouldContain("'Billing:All'");
+ }
+
+ [Fact]
+ public async Task returns_once_the_configured_shard_reports_the_sequence()
+ {
+ var (store, _) = storeWithShards(progress: [new ShardState("Account:All", 5), new ShardState("Other:All", 0)], "Account:All", "Other:All");
+
+ await EventStores.WaitForProjectionAsync(store, minSequence: 5, timeout: TimeSpan.FromMilliseconds(500));
+ }
+
+ private static (IEventStore Store, IEventDatabase Database) storeWithShards(IReadOnlyList progress, params string[] shardIdentities)
+ {
+ var database = Substitute.For();
+ database.Identifier.Returns("main");
+ database.AllProjectionProgress(Arg.Any()).Returns(Task.FromResult(progress));
+
+ var store = Substitute.For>();
+ store.AllDatabases().Returns(new ValueTask>([database]));
+
+ IReadOnlyList> shards = shardIdentities
+ .Select(identity => identity.Split(':'))
+ .Select(parts => new AsyncShard(
+ new AsyncOptions(),
+ default,
+ new ShardName(parts[0], parts[1], 1),
+ Substitute.For>(),
+ null!))
+ .ToList();
+ store.AllShards().Returns(shards);
+
+ return (store, database);
+ }
+}
diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/Domain.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/Domain.cs
new file mode 100644
index 000000000..be6c1199d
--- /dev/null
+++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/Domain.cs
@@ -0,0 +1,142 @@
+namespace Bobcat.CritterStack.ScenarioTests;
+
+// The domain the store-only vocabulary is specified against. Nothing here knows a store or a message bus:
+// the hosts register it on Marten and on Fisher, and every act is a delegate over the host's services.
+
+public record WalletOpened(Guid WalletId, string Owner);
+
+public record WalletCredited(Guid WalletId, decimal Amount);
+
+public record WalletDebited(Guid WalletId, decimal Amount);
+
+/// A strong-typed id around the Guid a stream is identified by (wolverine#4865).
+public readonly record struct WalletId(Guid Value);
+
+/// The write model: an inline snapshot, folded from its stream.
+public class Wallet
+{
+ public Guid Id { get; set; }
+ public string Owner { get; set; } = "";
+ public decimal Balance { get; set; }
+
+ public void Apply(WalletOpened e)
+ {
+ Id = e.WalletId;
+ Owner = e.Owner;
+ }
+
+ public void Apply(WalletCredited e) => Balance += e.Amount;
+ public void Apply(WalletDebited e) => Balance -= e.Amount;
+}
+
+/// An async-projected read model, so the projection waits have a daemon to wait on.
+public class WalletSummary
+{
+ public Guid Id { get; set; }
+ public int Credits { get; set; }
+ public decimal Balance { get; set; }
+
+ public void Apply(WalletOpened e) => Id = e.WalletId;
+
+ public void Apply(WalletCredited e)
+ {
+ Credits++;
+ Balance += e.Amount;
+ }
+
+ public void Apply(WalletDebited e) => Balance -= e.Amount;
+}
+
+/// A plain document, stored directly — the read-model arrange and the document reads.
+public class Customer
+{
+ public Guid Id { get; set; }
+ public string Name { get; set; } = "";
+ public string City { get; set; } = "";
+}
+
+/// A document type nothing ever stores, for "there is no T at all".
+public class Ghost
+{
+ public Guid Id { get; set; }
+}
+
+// ---- Dynamic Consistency Boundary tags (wolverine#4865) ---------------------------------------
+
+public readonly record struct ScreeningId(Guid Value);
+
+public record ScreeningScheduled(ScreeningId Screening, int Seats);
+
+public record SeatReserved(ScreeningId Screening, string Seat);
+
+public class SeatAvailability
+{
+ public Guid Id { get; set; }
+ public int Seats { get; set; }
+
+ public void Apply(ScreeningScheduled e) => Seats = e.Seats;
+ public void Apply(SeatReserved e) => Seats--;
+}
+
+/// A refusal an application throws, wrapped by its plumbing before the act sees it.
+public class WalletFrozen(Guid walletId) : Exception($"The wallet {walletId} is frozen")
+{
+ public Guid WalletId { get; } = walletId;
+}
+
+// ---- an ancillary store's side of the domain (bobcat#504) ---------------------------------------
+
+public record InvoiceRaised(Guid InvoiceId, decimal Amount);
+
+public record InvoicePaid(Guid InvoiceId);
+
+/// The ancillary store's write model: an inline snapshot there, registered nowhere else.
+public class Invoice
+{
+ public Guid Id { get; set; }
+ public decimal Amount { get; set; }
+ public bool Paid { get; set; }
+
+ public void Apply(InvoiceRaised e)
+ {
+ Id = e.InvoiceId;
+ Amount = e.Amount;
+ }
+
+ public void Apply(InvoicePaid e) => Paid = true;
+}
+
+/// The ancillary store's async read model, so a wait has a second daemon to cover.
+public class InvoiceSummary
+{
+ public Guid Id { get; set; }
+ public decimal Raised { get; set; }
+ public bool Paid { get; set; }
+
+ public void Apply(InvoiceRaised e)
+ {
+ Id = e.InvoiceId;
+ Raised += e.Amount;
+ }
+
+ public void Apply(InvoicePaid e) => Paid = true;
+}
+
+/// A document both stores register, so a step about it cannot be routed by type.
+public class SharedNote
+{
+ public Guid Id { get; set; }
+ public string Text { get; set; } = "";
+}
+
+/// A document no store registers, and nothing ever stores.
+public class Unclaimed
+{
+ public Guid Id { get; set; }
+}
+
+/// A document no store registers until the default store is handed one.
+public class Stray
+{
+ public Guid Id { get; set; }
+}
diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/EventStoreAuthoringIsPublicTests.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/EventStoreAuthoringIsPublicTests.cs
new file mode 100644
index 000000000..7bc5e8fdf
--- /dev/null
+++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/EventStoreAuthoringIsPublicTests.cs
@@ -0,0 +1,74 @@
+using System.Reflection;
+using Bobcat.CritterStack;
+using JasperFx.Events;
+using Shouldly;
+
+namespace Bobcat.CritterStack.ScenarioTests;
+
+///
+/// is part of the public store vocabulary, because a projected
+/// spec has to reach it (issue #324).
+///
+///
+///
+/// This test cannot fail from inside this assembly the way a consumer would experience it —
+/// InternalsVisibleTo points here, so the type was always reachable from these tests even
+/// while it was internal. That is precisely why the regression went unnoticed: every test
+/// that used it passed. So the assertion is on the REFLECTED accessibility rather than on the call
+/// compiling, which is the thing an outside consumer actually depends on.
+///
+///
+/// The failure it guards is not hypothetical. CritterCrush's Lane A rebuild could not arrange a
+/// stream from a plain xUnit test, and the compiler said CS0122: 'EventStoreAuthoring' is
+/// inaccessible due to its protection level.
+///
+///
+public class EventStoreAuthoringIsPublicTests
+{
+ [Fact]
+ public void the_type_is_visible_outside_this_assembly()
+ {
+ typeof(EventStoreAuthoring).IsPublic
+ .ShouldBeTrue("a projected spec outside Bobcat.CritterStack must be able to name it");
+ }
+
+ [Fact]
+ public void both_operations_are_callable_and_need_nothing_but_an_event_store()
+ {
+ // The whole point: no IStepContext, no fixture, no run in either signature — otherwise the
+ // type being public buys the projected lane nothing.
+ var append = typeof(EventStoreAuthoring).GetMethod(nameof(EventStoreAuthoring.AppendAsync));
+ var load = typeof(EventStoreAuthoring).GetMethod(nameof(EventStoreAuthoring.LoadDocumentAsync));
+
+ append.ShouldNotBeNull();
+ load.ShouldNotBeNull();
+
+ // NOT MethodInfo.IsPublic — that is true for a public method on an internal type, so it
+ // would pass in exactly the state this file exists to prevent. Reachability is the TYPE's
+ // property, asserted above; what belongs here is the shape of the signatures.
+ append!.GetParameters()[0].ParameterType.ShouldBe(typeof(IEventStore));
+ load!.GetParameters()[0].ParameterType.ShouldBe(typeof(IEventStore));
+
+ foreach (var method in new[] { append, load })
+ {
+ method.GetParameters()
+ .ShouldAllBe(p => !p.ParameterType.FullName!.Contains("IStepContext"),
+ $"{method.Name} must stay reachable without a Bobcat run");
+ }
+ }
+
+ [Fact]
+ public void the_whole_store_vocabulary_tier_is_public_together()
+ {
+ // EventStoreAuthoring was the lone internal beside these, which is what made it read as an
+ // oversight rather than a boundary. Pinned so the tier does not drift apart again.
+ foreach (var type in new[]
+ {
+ typeof(EventStores), typeof(DocumentStores),
+ typeof(RecordBuilding), typeof(EventStoreAuthoring)
+ })
+ {
+ type.IsPublic.ShouldBeTrue($"{type.Name} is part of the public store vocabulary");
+ }
+ }
+}
diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/EventStoresTests.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/EventStoresTests.cs
new file mode 100644
index 000000000..433564136
--- /dev/null
+++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/EventStoresTests.cs
@@ -0,0 +1,446 @@
+using JasperFx.Events;
+using JasperFx.Events.Daemon;
+using JasperFx.Events.Projections;
+using NSubstitute;
+using Shouldly;
+
+namespace Bobcat.CritterStack.ScenarioTests;
+
+///
+/// The store-agnostic binding against fakes shaped like the three stores, so the two convention
+/// paths (aggregate through a session's Events; reset through Advanced) are proven
+/// without referencing Marten, Polecat or Fisher. The Marten leg is
+/// .
+///
+public class EventStoresTests
+{
+ public class Account
+ {
+ public Guid Id { get; set; }
+ }
+
+ /// A session as the stores shape one: IStorageOperations with an Events member.
+ public interface IFakeSession : IStorageOperations
+ {
+ IQueryEventStore Events { get; }
+ }
+
+ // --- aggregate ----------------------------------------------------------------------------
+
+ [Fact]
+ public async Task aggregates_through_the_read_only_view_when_it_is_a_query_event_store()
+ {
+ // Marten and Polecat: OpenReadOnlyEventStore() returns the session's IQueryEventStore.
+ var id = Guid.NewGuid();
+ var expected = new Account { Id = id };
+ var view = Substitute.For();
+ view.AggregateStreamAsync(id, token: Arg.Any()).Returns(expected);
+
+ var store = new FakeEventStore { ReadOnlyView = (IReadOnlyEventStore)view };
+
+ var aggregate = await EventStores.AggregateStreamAsync(store, id);
+
+ aggregate.ShouldBeSameAs(expected);
+ }
+
+ [Fact]
+ public async Task falls_back_to_a_session_opened_through_the_generic_closure()
+ {
+ // Fisher: the read-only view is a separate object, so a session is opened through
+ // IEventStore.OpenSession and its Events member is used.
+ var id = Guid.NewGuid();
+ var expected = new Account { Id = id };
+
+ var events = Substitute.For();
+ events.AggregateStreamAsync(id, token: Arg.Any()).Returns(expected);
+ var session = Substitute.For();
+ session.Events.Returns(events);
+
+ var database = Substitute.For();
+ var store = Substitute.For>();
+ store.AllDatabases().Returns(new ValueTask>([database]));
+ store.OpenReadOnlyEventStore().Returns(Substitute.For());
+ store.OpenSession(database).Returns(session);
+
+ var aggregate = await EventStores.AggregateStreamAsync(store, id);
+
+ aggregate.ShouldBeSameAs(expected);
+ await session.Received(1).DisposeAsync();
+ }
+
+ // --- a tenant's session (bobcat#536) ------------------------------------------------------
+
+ [Fact]
+ public async Task a_tenant_session_is_opened_for_the_tenant_on_the_database_the_store_finds_for_it()
+ {
+ var id = Guid.NewGuid();
+ var expected = new Account { Id = id };
+
+ var events = Substitute.For();
+ events.AggregateStreamAsync(id, token: Arg.Any()).Returns(expected);
+ var session = Substitute.For();
+ session.Events.Returns(events);
+
+ var first = Substitute.For();
+ var second = Substitute.For();
+ var store = Substitute.For>();
+ store.AllDatabases().Returns(new ValueTask>([first, second]));
+ store.FindDatabaseForTenantAsync("red", Arg.Any()).Returns(new ValueTask(second));
+ store.OpenSession(second, "red").Returns(session);
+
+ var aggregate = await EventStores.AggregateStreamAsync(store, id, "red");
+
+ aggregate.ShouldBeSameAs(expected);
+ store.DidNotReceive().OpenSession(Arg.Any());
+ store.DidNotReceive().OpenSession(first, Arg.Any());
+ await session.Received(1).DisposeAsync();
+ }
+
+ [Fact]
+ public async Task a_tenant_on_a_store_with_one_database_is_opened_on_that_database()
+ {
+ var only = Substitute.For();
+ var session = Substitute.For();
+ session.Events.Returns(Substitute.For());
+ var store = Substitute.For>();
+ store.AllDatabases().Returns(new ValueTask>([only]));
+ store.FindDatabaseForTenantAsync("red", Arg.Any()).Returns(new ValueTask((IEventDatabase?)null));
+ store.OpenSession(only, "red").Returns(session);
+
+ await EventStores.FetchStreamAsync(store, Guid.NewGuid(), "red");
+
+ store.Received(1).OpenSession(only, "red");
+ }
+
+ [Fact]
+ public async Task a_tenant_no_database_can_be_found_for_is_refused_rather_than_guessed()
+ {
+ var store = Substitute.For>();
+ store.AllDatabases().Returns(new ValueTask>(
+ [Substitute.For(), Substitute.For()]));
+ store.FindDatabaseForTenantAsync("green", Arg.Any()).Returns(new ValueTask((IEventDatabase?)null));
+
+ var ex = await Should.ThrowAsync(
+ () => EventStores.FetchStreamAsync(store, Guid.NewGuid(), "green"));
+
+ ex.Message.ShouldContain("'green'");
+ ex.Message.ShouldContain("2 databases");
+ store.DidNotReceive().OpenSession(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task no_tenant_opens_the_default_session_as_before()
+ {
+ var database = Substitute.For();
+ var session = Substitute.For();
+ session.Events.Returns(Substitute.For());
+ var store = Substitute.For>();
+ store.AllDatabases().Returns(new ValueTask>([database]));
+ store.OpenReadOnlyEventStore().Returns(Substitute.For());
+
+ await EventStores.FetchStreamAsync(store, Guid.NewGuid(), (string?)null);
+
+ store.Received(1).OpenReadOnlyEventStore();
+ store.DidNotReceive().OpenSession(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task explains_when_no_session_can_be_opened()
+ {
+ var store = Substitute.For>();
+ store.AllDatabases().Returns(new ValueTask>(Array.Empty()));
+ store.OpenReadOnlyEventStore().Returns(Substitute.For());
+
+ var ex = await Should.ThrowAsync(
+ () => EventStores.AggregateStreamAsync(store, Guid.NewGuid()));
+
+ ex.Message.ShouldContain("enumerates no databases");
+ }
+
+ [Fact]
+ public void a_session_without_a_query_event_store_is_reported_not_guessed()
+ {
+ var ex = Should.Throw(
+ () => EventStoreSessions.QueryEventStoreOf(new object()));
+
+ ex.Message.ShouldContain("no public 'Events' member");
+ }
+
+ /// A session as the stores shape one, with the full event operations FetchLatest is on.
+ public interface IFakeOperationsSession : IStorageOperations
+ {
+ IEventStoreOperations Events { get; }
+ }
+
+ [Fact]
+ public async Task fetches_the_latest_aggregate_through_the_sessions_event_operations()
+ {
+ // wolverine#4921: FetchLatest, the store's own answer whatever the projection lifecycle
+ var id = Guid.NewGuid();
+ var expected = new Account { Id = id };
+
+ var events = Substitute.For();
+ events.FetchLatest(id, Arg.Any()).Returns(new ValueTask(expected));
+ var session = Substitute.For();
+ session.Events.Returns(events);
+
+ var database = Substitute.For();
+ var store = Substitute.For>();
+ store.AllDatabases().Returns(new ValueTask>([database]));
+ store.OpenSession(database).Returns(session);
+
+ (await EventStores.FetchLatestAsync(store, id)).ShouldBeSameAs(expected);
+ await session.Received(1).DisposeAsync();
+ }
+
+ [Fact]
+ public void a_session_without_event_operations_is_reported_not_guessed()
+ {
+ Should.Throw(() => EventStoreSessions.EventStoreOperationsOf(new object()))
+ .Message.ShouldContain("IEventStoreOperations");
+ }
+
+ // --- reset convention ---------------------------------------------------------------------
+
+ [Fact]
+ public async Task reset_prefers_ResetAllData()
+ {
+ var store = new StoreWithResetAllData();
+ await StoreResetConvention.ResetAsync(store, CancellationToken.None);
+ store.Advanced.Calls.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task reset_accepts_the_Async_suffixed_spelling()
+ {
+ // Fisher spells it ResetAllDataAsync.
+ var store = new StoreWithResetAllDataAsync();
+ await StoreResetConvention.ResetAsync(store, CancellationToken.None);
+ store.Advanced.Calls.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task reset_falls_back_to_the_cleaner_pair()
+ {
+ var store = new StoreWithCleanerOnly();
+ await StoreResetConvention.ResetAsync(store, CancellationToken.None);
+ store.Advanced.Clean.Calls.ShouldBe(["events", "documents"]);
+ }
+
+ [Fact]
+ public async Task reset_says_what_it_looked_for_when_nothing_matches()
+ {
+ var ex = await Should.ThrowAsync(
+ () => StoreResetConvention.ResetAsync(new FakeEventStore(), CancellationToken.None));
+
+ ex.Message.ShouldContain("Advanced.ResetAllData(CancellationToken)");
+ ex.Message.ShouldContain("DeleteAllEventDataAsync");
+ ex.Message.ShouldContain("reset hook");
+ }
+
+ // --- projection waits ----------------------------------------------------------------------
+
+ [Fact]
+ public async Task no_async_shards_means_nothing_to_wait_for()
+ {
+ var database = databaseWithProgress([]);
+ var store = new FakeEventStore { Databases = [database] };
+
+ await EventStores.WaitForProjectionAsync(store, minSequence: 10, timeout: TimeSpan.FromMilliseconds(200));
+ }
+
+ [Fact]
+ public async Task a_name_matching_no_shard_fails_loudly_and_lists_the_shards()
+ {
+ var database = databaseWithProgress([new ShardState("Orders:All", 5), new ShardState("Billing:All", 5)]);
+ var store = new FakeEventStore { Databases = [database] };
+
+ var ex = await Should.ThrowAsync(
+ () => EventStores.WaitForProjectionAsync(store, minSequence: 1, timeout: TimeSpan.FromMilliseconds(200)));
+
+ ex.Message.ShouldContain("matches 'Account'");
+ ex.Message.ShouldContain("'Orders:All'");
+ ex.Message.ShouldContain("'Billing:All'");
+ }
+
+ [Fact]
+ public async Task waits_until_the_matching_shards_reach_the_sequence()
+ {
+ var calls = 0;
+ var database = Substitute.For();
+ database.AllProjectionProgress(Arg.Any()).Returns(_ =>
+ {
+ calls++;
+ IReadOnlyList progress =
+ [
+ new ShardState(ShardState.HighWaterMark, 10),
+ new ShardState("Account:All", calls >= 3 ? 10 : 4),
+ new ShardState("Other:All", 1), // never catches up, and must not be waited on
+ ];
+ return Task.FromResult(progress);
+ });
+ var store = new FakeEventStore { Databases = [database] };
+
+ await EventStores.WaitForProjectionAsync(store, minSequence: 10, timeout: TimeSpan.FromSeconds(5));
+
+ calls.ShouldBeGreaterThanOrEqualTo(3);
+ }
+
+ [Fact]
+ public async Task times_out_with_the_shard_positions()
+ {
+ var database = databaseWithProgress([new ShardState("Account:All", 2)]);
+ var store = new FakeEventStore { Databases = [database] };
+
+ var ex = await Should.ThrowAsync(
+ () => EventStores.WaitForProjectionAsync(store, minSequence: 9, timeout: TimeSpan.FromMilliseconds(150)));
+
+ ex.Message.ShouldContain("'Account:All' at 2");
+ }
+
+ [Fact]
+ public async Task the_target_without_a_sequence_is_the_stores_highest_event()
+ {
+ var database = databaseWithProgress([new ShardState("Account:All", 7)]);
+ database.FetchHighestEventSequenceNumber(Arg.Any()).Returns(7L);
+ var store = new FakeEventStore { Databases = [database] };
+
+ await EventStores.WaitForProjectionAsync(store, timeout: TimeSpan.FromMilliseconds(200));
+ }
+
+ [Fact]
+ public async Task non_stale_wait_goes_through_every_database()
+ {
+ var first = Substitute.For();
+ var second = Substitute.For();
+ var store = new FakeEventStore { Databases = [first, second] };
+
+ await EventStores.WaitForNonStaleProjectionsAsync(store, TimeSpan.FromSeconds(1));
+
+ await first.Received(1).WaitForNonStaleProjectionDataAsync(TimeSpan.FromSeconds(1));
+ await second.Received(1).WaitForNonStaleProjectionDataAsync(TimeSpan.FromSeconds(1));
+ }
+
+ // --- one tenant (wolverine#4962 phase 2, jasperfx#1005) ----------------------------------
+
+ /// A database-per-tenant store that can say which database holds a tenant.
+ private sealed class TenantedEventStore : FakeEventStore, IEventStore
+ {
+ public Dictionary Tenants { get; } = new();
+ public List Asked { get; } = new();
+
+ ValueTask IEventStore.FindDatabaseForTenantAsync(string tenantId, CancellationToken token)
+ {
+ Asked.Add(tenantId);
+ return ValueTask.FromResult(Tenants.GetValueOrDefault(tenantId));
+ }
+ }
+
+ [Fact]
+ public async Task a_tenant_wait_waits_on_only_the_database_the_store_finds_for_it()
+ {
+ var red = Substitute.For();
+ var blue = Substitute.For();
+ var store = new TenantedEventStore { Databases = [red, blue] };
+ store.Tenants["red"] = red;
+
+ await EventStores.WaitForNonStaleProjectionsAsync(store, "red", TimeSpan.FromSeconds(1));
+
+ store.Asked.ShouldBe(["red"]);
+ await red.Received(1).WaitForNonStaleProjectionDataAsync(TimeSpan.FromSeconds(1));
+ await blue.DidNotReceive().WaitForNonStaleProjectionDataAsync(Arg.Any());
+ }
+
+ [Fact]
+ public async Task a_tenant_the_store_cannot_place_waits_on_every_database()
+ {
+ var red = Substitute.For();
+ var blue = Substitute.For();
+ var store = new TenantedEventStore { Databases = [red, blue] };
+
+ await EventStores.WaitForNonStaleProjectionsAsync(store, "green", TimeSpan.FromSeconds(1));
+
+ store.Asked.ShouldBe(["green"]);
+ await red.Received(1).WaitForNonStaleProjectionDataAsync(TimeSpan.FromSeconds(1));
+ await blue.Received(1).WaitForNonStaleProjectionDataAsync(TimeSpan.FromSeconds(1));
+ }
+
+ [Fact]
+ public async Task a_single_database_store_answers_a_tenant_through_the_default_lookup()
+ {
+ // FakeEventStore keeps the interface's default FindDatabaseForTenantAsync: Single cardinality, one database
+ var only = Substitute.For();
+ var store = new FakeEventStore { Databases = [only] };
+
+ (await ((IEventStore)store).FindDatabaseForTenantAsync("anyone")).ShouldBeSameAs(only);
+
+ await EventStores.WaitForNonStaleProjectionsAsync(store, "anyone", TimeSpan.FromSeconds(1));
+
+ await only.Received(1).WaitForNonStaleProjectionDataAsync(TimeSpan.FromSeconds(1));
+ }
+
+ [Fact]
+ public async Task non_stale_wait_falls_back_to_the_coordinator_then_explains()
+ {
+ var store = new FakeEventStore();
+ var daemon = Substitute.For();
+ var coordinator = Substitute.For();
+ coordinator.DaemonForMainDatabase().Returns(daemon);
+
+ await EventStores.WaitForNonStaleProjectionsAsync(store, TimeSpan.FromSeconds(1), coordinator);
+ await daemon.Received(1).WaitForNonStaleData(TimeSpan.FromSeconds(1));
+
+ var ex = await Should.ThrowAsync(
+ () => EventStores.WaitForNonStaleProjectionsAsync(store, TimeSpan.FromSeconds(1)));
+ ex.Message.ShouldContain("IProjectionCoordinator");
+ }
+
+ private static IEventDatabase databaseWithProgress(IReadOnlyList progress)
+ {
+ var database = Substitute.For();
+ database.Identifier.Returns("main");
+ database.AllProjectionProgress(Arg.Any()).Returns(Task.FromResult(progress));
+ return database;
+ }
+
+ // --- fakes shaped like the stores' public surface --------------------------------------------
+
+ private sealed class StoreWithResetAllData : FakeEventStore
+ {
+ public Ops Advanced { get; } = new();
+
+ public sealed class Ops
+ {
+ public int Calls { get; private set; }
+ public Task ResetAllData(CancellationToken token) { Calls++; return Task.CompletedTask; }
+ }
+ }
+
+ private sealed class StoreWithResetAllDataAsync : FakeEventStore
+ {
+ public Ops Advanced { get; } = new();
+
+ public sealed class Ops
+ {
+ public int Calls { get; private set; }
+ public Task ResetAllDataAsync(CancellationToken token = default) { Calls++; return Task.CompletedTask; }
+ }
+ }
+
+ private sealed class StoreWithCleanerOnly : FakeEventStore
+ {
+ public Ops Advanced { get; } = new();
+
+ public sealed class Ops
+ {
+ public Cleaner Clean { get; } = new();
+ }
+
+ public sealed class Cleaner
+ {
+ public List Calls { get; } = [];
+ public Task DeleteAllEventDataAsync(CancellationToken token = default) { Calls.Add("events"); return Task.CompletedTask; }
+ public Task DeleteAllDocumentsAsync(CancellationToken token = default) { Calls.Add("documents"); return Task.CompletedTask; }
+ }
+ }
+}
diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/EventTagQueryDescriptionTests.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/EventTagQueryDescriptionTests.cs
new file mode 100644
index 000000000..3b7a4bf05
--- /dev/null
+++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/EventTagQueryDescriptionTests.cs
@@ -0,0 +1,31 @@
+using Bobcat.Engine;
+using JasperFx.Events.Tags;
+using Shouldly;
+
+namespace Bobcat.CritterStack.ScenarioTests;
+
+// JasperFx/wolverine#4865: a tag query reads by the names its tags were given. Moved here from Bobcat's
+// TagsAndStrongTypedIdsTests with EventStores.Describe (GH-5082, JasperFx/bobcat#755).
+public class EventTagQueryDescriptionTests
+{
+ private readonly record struct ScreeningId(Guid Value);
+
+ private readonly record struct SeatCode(string Value);
+
+ private record SeatReserved(ScreeningId Screening, SeatCode Seat);
+
+ private record SeatReleased(SeatCode Seat);
+
+ [Fact]
+ public void a_query_reads_as_the_tags_it_selects_by_with_any_event_types_it_narrows_to()
+ {
+ using var _ = ScenarioRecorder.Begin("Values", "a scenario", publisher: null, runId: Guid.NewGuid());
+ var screening = ScenarioValues.Declare(new ScreeningId(Guid.NewGuid()), "theScreeningId");
+ var seat = new SeatCode("4C");
+
+ var query = EventTagQuery.For(screening).AndEventsOfType().Or(seat);
+
+ EventStores.Describe(query)
+ .ShouldBe("events tagged #theScreeningId (SeatReserved, SeatReleased) or SeatCode(4C)");
+ }
+}
diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/FakeEventStore.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/FakeEventStore.cs
new file mode 100644
index 000000000..01bc77b4a
--- /dev/null
+++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/FakeEventStore.cs
@@ -0,0 +1,59 @@
+using System.Diagnostics;
+using System.Diagnostics.Metrics;
+using JasperFx.Descriptors;
+using JasperFx.Events;
+using JasperFx.Events.Daemon;
+using JasperFx.Events.Descriptors;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+
+namespace Bobcat.CritterStack.ScenarioTests;
+
+///
+/// The smallest concrete — every abstract member stubbed — so a test can
+/// shape a store's public surface (an Advanced property, say) the way a reflective
+/// convention sees it. NSubstitute proxies cannot grow extra public members, which is why this is a
+/// class rather than a substitute.
+///
+public class FakeEventStore : IEventStore
+{
+ public FakeEventStore(string name = "fake", string type = "fake")
+ {
+ Identity = new EventStoreIdentity(name, type);
+ }
+
+ public Uri Subject { get; } = new("fake://store");
+ public Meter Meter { get; } = new("Bobcat.CritterStack.Tests.Fake");
+ public ActivitySource ActivitySource { get; } = new("Bobcat.CritterStack.Tests.Fake");
+ public string MetricsPrefix => "fake";
+ public DatabaseCardinality DatabaseCardinality => DatabaseCardinality.Single;
+ public bool HasMultipleTenants => false;
+ public EventStoreIdentity Identity { get; }
+
+ public IReadOnlyList Databases { get; set; } = [];
+ public IReadOnlyEventStore ReadOnlyView { get; set; } = Substitute.For();
+
+ /// What describes the store as (bobcat#504); null by default.
+ public EventStoreUsage? Usage { get; set; }
+
+ /// How many times the store was asked to describe itself.
+ public int UsageReads { get; private set; }
+
+ public Task TryCreateUsage(CancellationToken token)
+ {
+ UsageReads++;
+ return Task.FromResult(Usage);
+ }
+
+ public ValueTask BuildProjectionDaemonAsync(string? tenantIdOrDatabaseIdentifier = null, ILogger? logger = null)
+ => throw new NotSupportedException();
+
+ public ValueTask BuildProjectionDaemonAsync(DatabaseId id) => throw new NotSupportedException();
+
+ public ValueTask> AllDatabases() => ValueTask.FromResult(Databases);
+
+ public IReadOnlyEventStore OpenReadOnlyEventStore() => ReadOnlyView;
+
+ public Task CompactStreamAsync(Guid streamId, CancellationToken token = default) => Task.CompletedTask;
+ public Task CompactStreamAsync(string streamKey, CancellationToken token = default) => Task.CompletedTask;
+}
diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/FakeProducts.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/FakeProducts.cs
new file mode 100644
index 000000000..c17fda7ba
--- /dev/null
+++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/FakeProducts.cs
@@ -0,0 +1,17 @@
+namespace Bobcat.CritterStack.ScenarioTests
+{
+ /// A marker interface an ancillary store is registered under, like AddMartenStore<T>'s.
+ public interface IFakeAncillaryStore;
+
+ namespace FakeProduct
+ {
+ /// Stands in for Marten.IDocumentStore: the service a product registers its main store under.
+ public interface IDocumentStore;
+ }
+
+ namespace OtherProduct
+ {
+ /// A second product's IDocumentStore, as Fisher.IDocumentStore beside Marten's.
+ public interface IDocumentStore;
+ }
+}
diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/PartialArrangementTests.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/PartialArrangementTests.cs
new file mode 100644
index 000000000..6db877bf5
--- /dev/null
+++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/PartialArrangementTests.cs
@@ -0,0 +1,124 @@
+using Bobcat.Engine;
+using Shouldly;
+
+namespace Bobcat.CritterStack.ScenarioTests;
+
+///
+/// Issue #241: a Given arranges history, and history is arranged by the fields the decision
+/// reads — never by every field the event happens to carry.
+///
+///
+/// The failure this fixes took out all thirteen scenarios of a scaffolded chapter at once:
+/// Cannot build 'HomeCheckAppointmentProposed' from the columns [ownerId, proposedFor], for
+/// a six-parameter record. It also put the curated model in an impossible position — elements:
+/// hints exist so records are rich, scenario with: exists so specs are pointed, and the first
+/// made the second impossible: enriching an event to six honest fields broke every scenario that
+/// arranged it with two.
+///
+public class PartialArrangementTests
+{
+ private record Proposed(Guid AppointmentId, Guid OwnerId, Guid ShelterId, Guid DogId, DateTimeOffset ProposedFor);
+
+ private static readonly Guid Owner = Guid.Parse("0e5e0001-0000-0000-0000-000000000001");
+
+ [Fact]
+ public void a_given_arranges_the_fields_it_names_and_defaults_the_rest()
+ {
+ var built = (Proposed)RecordBuilding.Build(typeof(Proposed), new Dictionary
+ {
+ ["OwnerId"] = Owner.ToString(), ["ProposedFor"] = "2026-10-01T15:00:00Z",
+ }, partial: true);
+
+ built.OwnerId.ShouldBe(Owner);
+ built.ProposedFor.ShouldBe(DateTimeOffset.Parse("2026-10-01T15:00:00Z"));
+
+ // The scenario said nothing about these. Since bobcat#421 they are fresh ids rather than
+ // Guid.Empty, so two unspecified ids are never accidentally the same identity.
+ new[] { built.AppointmentId, built.ShelterId, built.DogId }.ShouldAllBe(x => x != Guid.Empty);
+ new[] { built.AppointmentId, built.ShelterId, built.DogId }.Distinct().Count().ShouldBe(3);
+ }
+
+ [Fact]
+ public void an_act_is_partial_too()
+ {
+ // #241 kept the act strict; bobcat#419 relaxed it, as #241's own author preferred. A command
+ // with a defaulted field is a real thing to specify, and a typo is still refused by name.
+ var built = (Proposed)RecordBuilding.Build(typeof(Proposed), new Dictionary
+ {
+ ["OwnerId"] = Owner.ToString(),
+ });
+
+ built.OwnerId.ShouldBe(Owner);
+ built.DogId.ShouldNotBe(Guid.Empty);
+ }
+
+ [Fact]
+ public void a_column_matching_nothing_is_refused_by_name()
+ {
+ // The case actually worth failing on. Relaxing the missing-column rule removed the
+ // accident that used to catch a typo, so it is checked deliberately instead.
+ var ex = Should.Throw(() =>
+ RecordBuilding.Build(typeof(Proposed), new Dictionary
+ {
+ ["OwnerId"] = Owner.ToString(), ["ProposdFor"] = "2026-10-01T15:00:00Z",
+ }, partial: true));
+
+ ex.Message.ShouldContain("ProposdFor");
+ ex.Message.ShouldContain("the column [ProposdFor] matches nothing on 'Proposed'");
+ }
+
+ ///
+ /// The verb agrees with the count. Only the noun used to be pluralized, so a single bad column
+ /// read "the column [X] match nothing" — which is the message a reader meets at the exact
+ /// moment they have mistyped something, so it is the worst place to look careless.
+ ///
+ [Fact]
+ public void the_unmatched_column_message_agrees_in_number()
+ {
+ var one = Should.Throw(() =>
+ RecordBuilding.Build(typeof(Proposed), new Dictionary
+ {
+ ["OwnerId"] = Owner.ToString(), ["ProposdFor"] = "2026-10-01T15:00:00Z",
+ }, partial: true));
+
+ one.Message.ShouldContain("the column [ProposdFor] matches nothing");
+
+ var many = Should.Throw(() =>
+ RecordBuilding.Build(typeof(Proposed), new Dictionary
+ {
+ ["OwnerId"] = Owner.ToString(), ["ProposdFor"] = "x", ["Wieght"] = "y",
+ }, partial: true));
+
+ many.Message.ShouldContain("match nothing");
+ many.Message.ShouldNotContain("matches nothing");
+ }
+
+ [Fact]
+ public void an_empty_unmatched_cell_is_a_union_table_not_a_typo()
+ {
+ // One `Given events for …` table may carry rows of several event types; its header is then
+ // the union of their fields, blank where a column does not apply to that row. A typo always
+ // arrives with a value in it, so emptiness is the discriminator.
+ var built = (Proposed)RecordBuilding.Build(typeof(Proposed), new Dictionary
+ {
+ ["OwnerId"] = Owner.ToString(), ["Reason"] = "",
+ }, partial: true);
+
+ built.OwnerId.ShouldBe(Owner);
+ }
+
+ [Fact]
+ public void a_defaulted_parameter_still_wins_over_the_partial_default()
+ {
+ // The #177 path is unchanged: an explicit C# default is a value the author chose, and it
+ // must still beat default(T).
+ var built = (Sized)RecordBuilding.Build(typeof(Sized), new Dictionary
+ {
+ ["Name"] = "n",
+ }, partial: true);
+
+ built.Count.ShouldBe(3);
+ }
+
+ private record Sized(string Name, int Count = 3);
+}
diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/PostgresEnvironment.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/PostgresEnvironment.cs
new file mode 100644
index 000000000..f7199a83d
--- /dev/null
+++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/PostgresEnvironment.cs
@@ -0,0 +1,79 @@
+using Npgsql;
+using Xunit;
+
+namespace Bobcat.CritterStack.ScenarioTests;
+
+///
+/// Where the Marten integration tests find their database, and whether one is actually there.
+///
+public static class PostgresEnvironment
+{
+ private static readonly Lazy _available = new(probe, isThreadSafe: true);
+
+ ///
+ /// This repository's docker-compose Postgres (5433), or whatever ConnectionStrings__postgres /
+ /// WOLVERINE_POSTGRES name: is the one place that knows.
+ ///
+ public static string ConnectionString => IntegrationTests.Servers.PostgresConnectionString;
+
+ /// GitHub Actions (and most CI systems) set CI=true.
+ public static bool IsCi =>
+ string.Equals(Environment.GetEnvironmentVariable("CI"), "true", StringComparison.OrdinalIgnoreCase);
+
+ public static bool IsAvailable => _available.Value;
+
+ public static string SkipReason =>
+ $"No Postgres reachable at {describe()}. Run `docker compose up -d postgresql` from the repo root, " +
+ "or point ConnectionStrings__postgres (or WOLVERINE_POSTGRES) at your own database. (This skip never applies on CI.)";
+
+ private static bool probe()
+ {
+ try
+ {
+ var builder = new NpgsqlConnectionStringBuilder(ConnectionString) { Timeout = 3 };
+ using var connection = new NpgsqlConnection(builder.ConnectionString);
+ connection.Open();
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ /// Host/database only — never echo the password into test output.
+ private static string describe()
+ {
+ try
+ {
+ var builder = new NpgsqlConnectionStringBuilder(ConnectionString);
+ return $"{builder.Host}:{builder.Port}/{builder.Database}";
+ }
+ catch
+ {
+ return "the configured connection string";
+ }
+ }
+}
+
+///
+/// A for tests that need a real Postgres.
+///
+/// Off CI, a missing database skips the test with an actionable message so a fresh clone
+/// still runs green without Docker. On CI the skip is deliberately NOT applied — the workflow
+/// provides a Postgres service, and a missing database there has to fail the build rather than
+/// quietly report a pass.
+///
+public sealed class PostgresFactAttribute : FactAttribute
+{
+ public PostgresFactAttribute(
+ [System.Runtime.CompilerServices.CallerFilePath] string? sourceFilePath = null,
+ [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = -1)
+ : base(sourceFilePath, sourceLineNumber)
+ {
+ if (!PostgresEnvironment.IsCi && !PostgresEnvironment.IsAvailable)
+ {
+ Skip = PostgresEnvironment.SkipReason;
+ }
+ }
+}
diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/RecordBuildingIsPublicApiTests.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/RecordBuildingIsPublicApiTests.cs
new file mode 100644
index 000000000..141584b62
--- /dev/null
+++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/RecordBuildingIsPublicApiTests.cs
@@ -0,0 +1,97 @@
+using System.Reflection;
+using Bobcat;
+using Bobcat.Engine;
+using Bobcat.CritterStack;
+using Shouldly;
+
+namespace Bobcat.CritterStack.ScenarioTests;
+
+///
+/// Issue #272: is public API, because [IncludeGrammars] is the
+/// designed extension point and any custom grammar taking a needs exactly
+/// this conversion.
+///
+///
+/// The accessibility is asserted by reflection rather than by the tests merely compiling, because
+/// this project has InternalsVisibleTo — it would compile against an internal class
+/// just as happily, which is how the surface could silently close again. The consumer that matters
+/// is outside the Bobcat assemblies and has no such grant.
+///
+public class RecordBuildingIsPublicApiTests
+{
+ // The shape a grammar module written outside Bobcat actually has: a step text, a trailing
+ // table, and a runtime Type it must turn into an object. Named Parcel rather than Shipment so
+ // it cannot collide by simple name with the document-lane sample (issue #270) — a {document}
+ // capture resolves by simple name across the whole compilation, and two of them is BOBCAT012.
+ public record Parcel(string Origin, string Destination, decimal WeightKg, string? Carrier = null);
+
+ [Fact]
+ public void RecordBuilding_is_reachable_from_outside_the_assembly()
+ {
+ typeof(RecordBuilding).IsPublic.ShouldBeTrue(
+ "a custom [IncludeGrammars] module lives outside Bobcat and has no InternalsVisibleTo grant");
+ }
+
+ [Fact]
+ public void Build_and_BuildAll_are_both_part_of_that_surface()
+ {
+ var members = typeof(RecordBuilding)
+ .GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)
+ .Select(m => m.Name)
+ .ToList();
+
+ members.ShouldContain(nameof(RecordBuilding.Build));
+ members.ShouldContain(nameof(RecordBuilding.BuildAll));
+ }
+
+ [Fact]
+ public void a_custom_grammar_can_build_a_row_the_way_the_shipped_ones_do()
+ {
+ var table = new StepTable(
+ ["Origin", "Destination", "WeightKg"],
+ [["Dallas", "Austin", "12.5"]]);
+
+ var built = (Parcel)RecordBuilding.BuildAll(typeof(Parcel), table).Single();
+
+ built.Origin.ShouldBe("Dallas");
+ built.Destination.ShouldBe("Austin");
+ built.WeightKg.ShouldBe(12.5m);
+ // A trailing optional needs no column — the same rule the shipped grammars get.
+ built.Carrier.ShouldBeNull();
+ }
+
+ [Fact]
+ public void the_conventions_a_custom_grammar_inherits_include_the_unmatched_column_message()
+ {
+ var table = new StepTable(
+ ["Origin", "Destination", "WeightKg", "Wieght"],
+ [["Dallas", "Austin", "12.5", "12.5"]]);
+
+ var ex = Should.Throw(() =>
+ RecordBuilding.BuildAll(typeof(Parcel), table, "Given shipments exist"));
+
+ // The point of sharing the helper: one typo message, not one per consumer's copy.
+ ex.Message.ShouldContain("Wieght");
+ ex.Message.ShouldContain("Given shipments exist");
+ }
+
+ [Fact]
+ public void BuildAll_carries_the_partial_arrange_rule_through_to_every_row()
+ {
+ var table = new StepTable(
+ ["Origin"],
+ [["Dallas"], ["Austin"]]);
+
+ // Every build is partial since bobcat#419; the flag is kept for compatibility and changes nothing.
+ RecordBuilding.BuildAll(typeof(Parcel), table).Count.ShouldBe(2);
+
+ var arranged = RecordBuilding.BuildAll(typeof(Parcel), table, partial: true)
+ .Cast()
+ .ToList();
+
+ arranged.Select(s => s.Origin).ShouldBe(["Dallas", "Austin"]);
+ // A non-nullable string nobody specified is "" since bobcat#421, never a surprise null
+ arranged.ShouldAllBe(s => s.Destination == "");
+ arranged.ShouldAllBe(s => s.Carrier == null);
+ }
+}
diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/RecordBuildingTests.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/RecordBuildingTests.cs
new file mode 100644
index 000000000..719959e9f
--- /dev/null
+++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/RecordBuildingTests.cs
@@ -0,0 +1,86 @@
+using Bobcat.Engine;
+using Shouldly;
+
+namespace Bobcat.CritterStack.ScenarioTests;
+
+///
+/// bobcat#177 dogfood follow-on: a constructor parameter with a C# default does not need a table
+/// column. Stoat's ClaimNode(string NodeClaimId, string Plan, string NodeId, string Agent,
+/// TimeSpan Lease, string? Session = null) is the motivating shape — before this, every
+/// When ClaimNode is received table had to carry a Session column saying nothing.
+///
+public class RecordBuildingTests
+{
+ private record Claim(string Plan, string Node, string Agent, TimeSpan Lease, string? Session = null);
+ private record Sized(string Name, int Count = 3, TimeSpan Window = default);
+
+ [Fact]
+ public void a_defaulted_parameter_needs_no_column()
+ {
+ var built = (Claim)RecordBuilding.Build(typeof(Claim), new Dictionary
+ {
+ ["Plan"] = "dogfood", ["Node"] = "spec-suite", ["Agent"] = "claude", ["Lease"] = "00:30:00",
+ });
+
+ built.ShouldBe(new Claim("dogfood", "spec-suite", "claude", TimeSpan.FromMinutes(30)));
+ built.Session.ShouldBeNull();
+ }
+
+ [Fact]
+ public void a_supplied_column_still_beats_the_default()
+ {
+ var built = (Claim)RecordBuilding.Build(typeof(Claim), new Dictionary
+ {
+ ["Plan"] = "dogfood", ["Node"] = "spec-suite", ["Agent"] = "claude",
+ ["Lease"] = "00:30:00", ["Session"] = "abc-123",
+ });
+
+ built.Session.ShouldBe("abc-123");
+ }
+
+ [Fact]
+ public void a_value_type_defaulted_to_default_materializes_the_actual_default()
+ {
+ // `TimeSpan Window = default` reports a null DefaultValue through reflection; the builder
+ // must hand the constructor default(TimeSpan), not null.
+ var built = (Sized)RecordBuilding.Build(typeof(Sized), new Dictionary
+ {
+ ["Name"] = "n",
+ });
+
+ built.Count.ShouldBe(3);
+ built.Window.ShouldBe(TimeSpan.Zero);
+ }
+
+ [Fact]
+ public void a_missing_non_defaulted_column_is_defaulted_now_that_an_act_is_partial()
+ {
+ // Issue #233 refused this by name; bobcat#419 made the act partial, as #241 preferred. The
+ // refusal that remains is for a column matching nothing — the typo worth catching.
+ var claim = (Claim)RecordBuilding.Build(typeof(Claim), new Dictionary { ["Plan"] = "p" });
+
+ claim.Plan.ShouldBe("p");
+ }
+
+ [Fact]
+ public void the_refusal_names_the_step_when_a_step_asked()
+ {
+ // What the reader used to get instead was `NullReferenceException at
+ // CritterStackFixture.WhenCommandIsReceived`, which names Bobcat's stack, not their spec.
+ var ex = Should.Throw(() =>
+ RecordBuilding.Build(typeof(Claim), new Dictionary { ["Plann"] = "p" }, "When Claim is received"));
+
+ ex.Message.ShouldStartWith("'When Claim is received': the column [Plann] matches nothing on 'Claim'");
+ }
+
+ [Fact]
+ public void a_field_less_record_needs_no_columns_at_all()
+ {
+ // Issue #233's second question, answered yes: `new Beat()` is a perfectly good act, and an
+ // emlang import carries no field information, so scaffolded scenarios routinely have
+ // nothing to put in a table.
+ RecordBuilding.Build(typeof(Beat), new Dictionary()).ShouldBe(new Beat());
+ }
+}
+
+public record Beat();
diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/Recordings.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/Recordings.cs
new file mode 100644
index 000000000..e5a5a416d
--- /dev/null
+++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/Recordings.cs
@@ -0,0 +1,50 @@
+using Bobcat.Monitoring;
+
+namespace Bobcat.CritterStack.ScenarioTests;
+
+///
+/// Opens a Bobcat recording around a block of steps, so a test can read back exactly what the specification
+/// rendered — each step's keyword, text, cells and verdict — and what reached the monitor wire.
+///
+public static class Recordings
+{
+ public static async Task RecordAsync(Func steps, IMonitorEventSink? sink = null)
+ {
+ var recording = ScenarioRecorder.Begin("CritterStackScenario", Guid.NewGuid().ToString(), sink, Guid.NewGuid());
+ try
+ {
+ await steps();
+ }
+ finally
+ {
+ recording.Dispose();
+ }
+
+ return recording;
+ }
+
+ public static IEnumerable Lines(this ScenarioRecorder.Recording recording)
+ => recording.Steps.Select(x => $"{x.Keyword} {x.Text}");
+
+ public static ScenarioRecorder.RecordedStep Step(this ScenarioRecorder.Recording recording, string text)
+ => recording.Steps.Single(x => x.Text == text);
+}
+
+/// Every monitor event a scenario posted, in order.
+public sealed class RecordingSink : IMonitorEventSink
+{
+ private readonly List _events = new();
+
+ public void Post(MonitorEvent @event)
+ {
+ lock (_events) _events.Add(@event);
+ }
+
+ public IReadOnlyList Events
+ {
+ get
+ {
+ lock (_events) return _events.ToArray();
+ }
+ }
+}
diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/StoreHosts.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/StoreHosts.cs
new file mode 100644
index 000000000..f2dd183ee
--- /dev/null
+++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/StoreHosts.cs
@@ -0,0 +1,224 @@
+using Fisher;
+using JasperFx;
+using JasperFx.Events.Daemon;
+using JasperFx.Events.Projections;
+using Marten;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace Bobcat.CritterStack.ScenarioTests;
+
+///
+/// An application with an event store and nothing else: no Wolverine, no message bus. One per store, shared
+/// by a test class; every test uses fresh stream ids, so they never see each other's events.
+///
+public abstract class StoreHost : IAsyncLifetime
+{
+ private IHost? _host;
+
+ /// The host, once started. Null when the store is not available here and the test skips.
+ public IHost Host => _host ?? throw new InvalidOperationException("The store host did not start");
+
+ /// Whether this store can run here. Postgres is skipped locally when absent, never on CI.
+ public virtual bool IsAvailable => true;
+
+ public virtual string SkipReason => "";
+
+ /// Every error the host logged — the async daemon's included (bobcat#680).
+ public CapturedErrors Errors { get; } = new();
+
+ protected abstract IHost Build();
+
+ public async ValueTask InitializeAsync()
+ {
+ if (!IsAvailable) return;
+
+ await BeforeStartAsync();
+ try
+ {
+ _host = Build();
+ await _host.StartAsync();
+ await AfterStartAsync();
+ }
+ finally
+ {
+ AfterStartAttempt();
+ }
+ }
+
+ /// Runs whether or not the host started.
+ protected virtual void AfterStartAttempt()
+ {
+ }
+
+ protected virtual Task BeforeStartAsync() => Task.CompletedTask;
+
+ protected virtual Task AfterStartAsync() => Task.CompletedTask;
+
+ public virtual async ValueTask DisposeAsync()
+ {
+ if (_host is null) return;
+
+ await _host.StopAsync();
+ _host.Dispose();
+ }
+}
+
+public class MartenStoreHost : StoreHost
+{
+ private static int _hosts;
+
+ // A schema per host: the test classes' hosts start in parallel, and Marten's DDL races on one schema.
+ // Dropped before (a previous run's leftovers) and after.
+ private readonly string _schema = $"bobcat_cs_scenario_{Interlocked.Increment(ref _hosts)}";
+
+ public override bool IsAvailable => PostgresEnvironment.IsCi || PostgresEnvironment.IsAvailable;
+
+ public override string SkipReason => PostgresEnvironment.SkipReason;
+
+ /// The WalletSummary projection's lifecycle: Async, unless a store with no async projection is wanted.
+ protected virtual SnapshotLifecycle SummaryLifecycle => SnapshotLifecycle.Async;
+
+ protected override IHost Build()
+ {
+ var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder();
+ builder.Logging.AddProvider(Errors);
+ builder.Services.AddMarten(opts =>
+ {
+ opts.Connection(PostgresEnvironment.ConnectionString);
+ opts.DatabaseSchemaName = _schema;
+ opts.AutoCreateSchemaObjects = AutoCreate.All;
+ opts.DisableNpgsqlLogging = true;
+
+ opts.Projections.Snapshot(SnapshotLifecycle.Inline);
+ opts.Projections.Snapshot(SummaryLifecycle);
+
+ opts.Events.RegisterTagType("screening").ForAggregate();
+ opts.Projections.LiveStreamAggregation();
+ })
+ // The schema before the daemon starts: applied after, the two race on the same DDL
+ .ApplyAllDatabaseChangesOnStartup()
+ .AddAsyncDaemon(DaemonMode.Solo);
+
+ return builder.Build();
+ }
+
+ protected override async Task BeforeStartAsync()
+ {
+ // One host building its schema at a time: Postgres races on its own catalog (pg_type) when two
+ // schemas' DDL runs at once (shared with the two-store host, bobcat#504)
+ await MartenDdl.Gate.WaitAsync();
+ await dropSchemaAsync();
+ }
+
+ protected override void AfterStartAttempt() => MartenDdl.Gate.Release();
+
+ public override async ValueTask DisposeAsync()
+ {
+ await base.DisposeAsync();
+ if (IsAvailable) await dropSchemaAsync();
+ }
+
+ private async Task dropSchemaAsync()
+ {
+ await using var connection = new Npgsql.NpgsqlConnection(PostgresEnvironment.ConnectionString);
+ await connection.OpenAsync();
+ await using var command = connection.CreateCommand();
+ command.CommandText = $"DROP SCHEMA IF EXISTS {_schema} CASCADE";
+ await command.ExecuteNonQueryAsync();
+ }
+}
+
+public class FisherStoreHost : StoreHost
+{
+ private readonly string _file = Path.Combine(Path.GetTempPath(), $"bobcat-critterstack-scenario-{Guid.NewGuid():N}.db");
+
+ /// The WalletSummary projection's lifecycle: Async, unless a store with no async projection is wanted.
+ protected virtual SnapshotLifecycle SummaryLifecycle => SnapshotLifecycle.Async;
+
+ protected override IHost Build()
+ {
+ var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder();
+ builder.Logging.AddProvider(Errors);
+ builder.Services.AddFisher(opts =>
+ {
+ opts.Connection($"Data Source={_file}");
+
+ opts.Projections.Snapshot(SnapshotLifecycle.Inline);
+ opts.Projections.Snapshot(SummaryLifecycle);
+
+ opts.Events.RegisterTagType("screening").ForAggregate();
+ opts.Projections.LiveStreamAggregation();
+ })
+ // Fisher builds its schema lazily, and the daemon reads its progression table on start
+ .ApplyAllDatabaseChangesOnStartup()
+ .AddAsyncDaemon(DaemonMode.Solo);
+
+ return builder.Build();
+ }
+
+ public override async ValueTask DisposeAsync()
+ {
+ await base.DisposeAsync();
+
+ foreach (var file in new[] { _file, _file + "-wal", _file + "-shm" })
+ {
+ try
+ {
+ File.Delete(file);
+ }
+ catch (IOException)
+ {
+ // A temp file; the OS reclaims it
+ }
+ }
+ }
+}
+
+/// bobcat#620: with every projection inline, so the store has no async shard.
+public sealed class InlineOnlyMartenStoreHost : MartenStoreHost
+{
+ protected override SnapshotLifecycle SummaryLifecycle => SnapshotLifecycle.Inline;
+}
+
+/// bobcat#620: with every projection inline, so the store has no async shard.
+public sealed class InlineOnlyFisherStoreHost : FisherStoreHost
+{
+ protected override SnapshotLifecycle SummaryLifecycle => SnapshotLifecycle.Inline;
+}
+
+/// A logger provider keeping every error-level entry, with its exception, for a test to assert on.
+public sealed class CapturedErrors : ILoggerProvider
+{
+ private readonly List _entries = [];
+
+ public IReadOnlyList Entries
+ {
+ get
+ {
+ lock (_entries) return _entries.ToList();
+ }
+ }
+
+ public ILogger CreateLogger(string categoryName) => new Logger(this, categoryName);
+
+ public void Dispose()
+ {
+ }
+
+ private sealed class Logger(CapturedErrors errors, string category) : ILogger
+ {
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+
+ public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Error;
+
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
+ Func formatter)
+ {
+ if (!IsEnabled(logLevel)) return;
+
+ lock (errors._entries) errors._entries.Add($"{category}: {formatter(state, exception)} {exception}");
+ }
+ }
+}
diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/Usings.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/Usings.cs
new file mode 100644
index 000000000..c802f4480
--- /dev/null
+++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/Usings.cs
@@ -0,0 +1 @@
+global using Xunit;
diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/ancillary_stores.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/ancillary_stores.cs
new file mode 100644
index 000000000..1075eb7e5
--- /dev/null
+++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/ancillary_stores.cs
@@ -0,0 +1,255 @@
+using Bobcat.CritterStack;
+using Bobcat.Engine;
+using JasperFx.Events;
+using Shouldly;
+
+namespace Bobcat.CritterStack.ScenarioTests;
+
+// bobcat#504: a specification over an application with a main event store and an ancillary one. Steps go to the
+// store whose registrations claim their types (A), In() / In(name) address one explicitly (B), and an act
+// is observed on every store (C). Each test runs on Marten and on Fisher through the classes at the bottom.
+
+public abstract class ancillary_stores(THost host) where THost : TwoStoreHost where TMarker : class
+{
+ private CritterStackScenario newScenario(string? storeName = null)
+ {
+ Assert.SkipUnless(host.IsAvailable, host.SkipReason);
+ return new CritterStackScenario(host.Host, storeName);
+ }
+
+ private IEventStore main => host.Host.Services.EventStore("main");
+ private IEventStore invoicing => host.Host.Services.EventStore(host.AncillaryName);
+
+ private static Task append(IServiceProvider services, string store, Type aggregate, Guid id, params object[] events)
+ => EventStoreAuthoring.AppendAsync(services.EventStore(store), aggregate, id, events);
+
+ // ---- A: route by type --------------------------------------------------------------------
+
+ [Fact]
+ public async Task givens_and_thens_go_to_the_store_that_registers_their_type()
+ {
+ // Every step here names a type exactly one store registers, so the types decide
+ var scenario = newScenario();
+ var wallet = Guid.NewGuid();
+ var invoice = Guid.NewGuid();
+ var loose = Guid.NewGuid();
+ var summary = Guid.NewGuid();
+
+ var recording = await Recordings.RecordAsync(async () =>
+ {
+ await scenario.GivenEvents(wallet, new WalletOpened(wallet, "Ann"));
+ await scenario.GivenEventsOn(invoice, new InvoiceRaised(invoice, 40m));
+ // No aggregate type: its events say where it goes
+ await scenario.GivenEvents(loose, new InvoiceRaised(loose, 5m));
+ await scenario.GivenReadModel(new InvoiceSummary { Id = summary, Raised = 9m });
+
+ await scenario.ThenAggregate(invoice, Specifications.Specify().With(x => x.Amount, 40m));
+ await scenario.ThenAggregate(wallet, Specifications.Specify().With(x => x.Owner, "Ann"));
+ await scenario.ThenDocument(summary, Specifications.Specify().With(x => x.Raised, 9m));
+ });
+
+ recording.GatheredFailures().ShouldBeNull();
+
+ (await EventStores.FetchStreamAsync(invoicing, invoice)).Count.ShouldBe(1);
+ (await EventStores.FetchStreamAsync(main, invoice)).Count.ShouldBe(0);
+ (await EventStores.FetchStreamAsync(main, wallet)).Count.ShouldBe(1);
+ (await EventStores.FetchStreamAsync(invoicing, wallet)).Count.ShouldBe(0);
+ (await EventStores.FetchStreamAsync(invoicing, loose)).Count.ShouldBe(1);
+ (await EventStoreAuthoring.LoadDocumentAsync(invoicing, summary)).ShouldNotBeNull();
+ (await scenario.TheAggregate(invoice))!.Amount.ShouldBe(40m);
+ }
+
+ [Fact]
+ public async Task with_no_store_name_the_main_store_is_the_default()
+ {
+ // No storeName: of the two stores only one is the application's main store, so it is the default
+ var scenario = newScenario();
+ scenario.EventStore.ShouldBeSameAs(main);
+
+ var note = Guid.NewGuid();
+ var stray = Guid.NewGuid();
+ var invoice = Guid.NewGuid();
+
+ var recording = await Recordings.RecordAsync(async () =>
+ {
+ // Both stores register SharedNote, no store registers Stray: the main store takes both
+ await scenario.GivenReadModel(new SharedNote { Id = note, Text = "both register it" });
+ await scenario.GivenReadModel(new Stray { Id = stray });
+ // ...while a type the ancillary store registers still goes there
+ await scenario.GivenEvents(invoice, new InvoiceRaised(invoice, 1m));
+
+ await scenario.ThenDocument(note, Specifications.Specify().With(x => x.Text, "both register it"));
+ await scenario.ThenNoReadModel();
+ });
+
+ recording.GatheredFailures().ShouldBeNull();
+ (await EventStoreAuthoring.LoadDocumentAsync(main, note)).ShouldNotBeNull();
+ (await EventStoreAuthoring.LoadDocumentAsync(invoicing, note)).ShouldBeNull();
+ (await EventStoreAuthoring.LoadDocumentAsync(main, stray)).ShouldNotBeNull();
+ (await EventStores.FetchStreamAsync(invoicing, invoice)).Count.ShouldBe(1);
+
+ // A stream named by its id alone has no type to route by: it is read from the main store
+ (await scenario.TheStreamVersion(invoice)).ShouldBe(0);
+ (await scenario.In().TheStreamVersion(invoice)).ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task an_explicit_store_name_still_chooses_the_default_over_the_main_store()
+ {
+ // The ancillary store as the default: SharedNote is claimed by both, but the default can take it...
+ var scenario = newScenario(host.AncillaryName);
+ var note = Guid.NewGuid();
+ await scenario.GivenReadModel(new SharedNote { Id = note });
+ (await EventStoreAuthoring.LoadDocumentAsync(invoicing, note)).ShouldNotBeNull();
+
+ // ...and a type only the main store claims still goes to the main store
+ var wallet = Guid.NewGuid();
+ await scenario.GivenEvents(wallet, new WalletOpened(wallet, "Ed"));
+ (await EventStores.FetchStreamAsync(main, wallet)).Count.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task the_default_store_takes_what_no_store_or_several_stores_claim()
+ {
+ var scenario = newScenario("main");
+ var note = Guid.NewGuid();
+ var stray = Guid.NewGuid();
+ var invoice = Guid.NewGuid();
+
+ await scenario.GivenReadModel(new SharedNote { Id = note, Text = "both register it" });
+ await scenario.GivenReadModel(new Stray { Id = stray });
+ // ...while a type one store registers still goes to that store
+ await scenario.GivenEvents(invoice, new InvoiceRaised(invoice, 1m));
+
+ (await EventStoreAuthoring.LoadDocumentAsync(main, note)).ShouldNotBeNull();
+ (await EventStoreAuthoring.LoadDocumentAsync(invoicing, note)).ShouldBeNull();
+ (await EventStoreAuthoring.LoadDocumentAsync(main, stray)).ShouldNotBeNull();
+ (await EventStores.FetchStreamAsync(invoicing, invoice)).Count.ShouldBe(1);
+
+ // A stream named by its id alone is read from the default store; In() names the other
+ (await scenario.TheStreamVersion(invoice)).ShouldBe(0);
+ (await scenario.In().TheStreamVersion(invoice)).ShouldBe(1);
+ }
+
+ // ---- B: explicit addressing --------------------------------------------------------------
+
+ [Fact]
+ public async Task in_addresses_a_store_by_marker_or_by_name_within_the_same_specification()
+ {
+ var scenario = newScenario();
+ var note = Guid.NewGuid();
+ var other = Guid.NewGuid();
+ var invoice = Guid.NewGuid();
+
+ var recording = await Recordings.RecordAsync(async () =>
+ {
+ await scenario.In().GivenReadModel(new SharedNote { Id = note, Text = "invoicing" });
+ await scenario.In("main").GivenReadModel(new SharedNote { Id = other, Text = "main" });
+ await scenario.In().ThenDocument(note, Specifications.Specify().With(x => x.Text, "invoicing"));
+ await scenario.In("MAIN").ThenNoReadModel(note);
+
+ // An act through a view is the specification's act, and the scenario judges it
+ await scenario.In().WhenActing("an invoice is raised",
+ services => append(services, host.AncillaryName, typeof(Invoice), invoice, new InvoiceRaised(invoice, 2m)));
+ scenario.ThenEvents(new InvoiceRaised(invoice, 2m));
+ await scenario.In(host.AncillaryName).ThenEventsOn(invoice, 0, new InvoiceRaised(invoice, 2m));
+ });
+
+ recording.GatheredFailures().ShouldBeNull();
+ // Every step, through a view or not, renders in the one specification
+ recording.Steps.Count.ShouldBeGreaterThanOrEqualTo(7);
+
+ (await EventStoreAuthoring.LoadDocumentAsync(main, other))!.Text.ShouldBe("main");
+ (await scenario.In().TheStreamVersion(invoice)).ShouldBe(1);
+ scenario.In().EventStore.ShouldBeSameAs(invoicing);
+ scenario.In(host.AncillaryName).EventStore.ShouldBeSameAs(invoicing);
+ scenario.In().LastAct.ShouldBeSameAs(scenario.LastAct);
+
+ var unknown = Should.Throw(() => scenario.In("nope"));
+ unknown.Message.ShouldContain(host.AncillaryName);
+ }
+
+ // ---- C: every store observed during the act ----------------------------------------------
+
+ [Fact]
+ public async Task an_act_on_both_stores_is_observed_on_both_and_reported_by_store()
+ {
+ var scenario = newScenario();
+ var wallet = Guid.NewGuid();
+ var invoice = Guid.NewGuid();
+ var started = Guid.NewGuid();
+ await scenario.GivenEvents(wallet, new WalletOpened(wallet, "Bo"));
+ await scenario.GivenEventsOn(invoice, new InvoiceRaised(invoice, 12m));
+
+ var recording = await Recordings.RecordAsync(async () =>
+ {
+ await scenario.WhenActing("the invoice is paid from the wallet", async services =>
+ {
+ await append(services, "main", typeof(Wallet), wallet, new WalletDebited(wallet, 12m));
+ await append(services, host.AncillaryName, typeof(Invoice), invoice, new InvoicePaid(invoice));
+ await append(services, host.AncillaryName, typeof(Invoice), started, new InvoiceRaised(started, 3m));
+ });
+
+ scenario.ThenEventsInAnyOrder(new WalletDebited(wallet, 12m), new InvoicePaid(invoice), new InvoiceRaised(started, 3m));
+ scenario.ThenEventsOn(wallet, new WalletDebited(wallet, 12m));
+ scenario.ThenEventsOn(invoice, new InvoicePaid(invoice));
+ await scenario.ThenEventsOn(invoice, 1, new InvoicePaid(invoice));
+ await scenario.ThenStreamIsStartedWithEvents(new InvoiceRaised(started, 3m));
+ });
+
+ recording.GatheredFailures().ShouldBeNull();
+ scenario.TheStartedStream().ShouldBe(started);
+
+ // Which store each event came from
+ var act = scenario.LastAct;
+ act.NewEvents.Count.ShouldBe(3);
+ act.StoreOf(act.NewEvents.Single(x => x.Data is WalletDebited)).ShouldBeSameAs(main);
+ act.StoreOf(act.NewEvents.Single(x => x.Data is InvoicePaid)).ShouldBeSameAs(invoicing);
+ act.StoreOf(act.NewEvents.Single(x => x.Data is InvoiceRaised)).ShouldBeSameAs(invoicing);
+
+ // ...and a failing whole-store step says so
+ var wrong = await Recordings.RecordAsync(() =>
+ {
+ scenario.ThenNoEvents();
+ return Task.CompletedTask;
+ });
+ var failure = wrong.GatheredFailures().ShouldNotBeNull();
+ failure.ShouldContain("Appended by store — 'main': WalletDebited(");
+ failure.ShouldContain($"'{host.AncillaryName}': InvoicePaid(");
+ }
+
+ [Fact]
+ public async Task the_catch_up_waits_on_every_store_and_a_read_on_the_store_it_reads()
+ {
+ var scenario = newScenario();
+ var wallet = Guid.NewGuid();
+ var invoice = Guid.NewGuid();
+
+ await scenario.WhenActing("a wallet is opened and credited, and an invoice raised", async services =>
+ {
+ await append(services, "main", typeof(Wallet), wallet, new WalletOpened(wallet, "Cy"), new WalletCredited(wallet, 20m));
+ await append(services, host.AncillaryName, typeof(Invoice), invoice, new InvoiceRaised(invoice, 20m));
+ });
+
+ await scenario.AfterAsyncDaemonsCatchUpAsync();
+
+ // Read straight from each store, with no wait of their own: both daemons have caught up
+ (await EventStoreAuthoring.LoadDocumentAsync(main, wallet))!.Credits.ShouldBe(1);
+ (await EventStoreAuthoring.LoadDocumentAsync(invoicing, invoice))!.Raised.ShouldBe(20m);
+
+ // A read waits on the store it reads, which here is the ancillary one
+ var invoiceId = Guid.NewGuid();
+ await scenario.WhenActing("another invoice is raised",
+ services => append(services, host.AncillaryName, typeof(Invoice), invoiceId, new InvoiceRaised(invoiceId, 4m)));
+ await scenario.ThenReadModel(invoiceId, Specifications.Specify().With(x => x.Raised, 4m));
+
+ // A view waits on its own store
+ await scenario.In().AfterAsyncDaemonsCatchUpAsync();
+ }
+}
+
+public class ancillary_stores_on_marten(MartenTwoStoreHost host)
+ : ancillary_stores(host), IClassFixture;
+
+public class ancillary_stores_on_fisher(FisherTwoStoreHost host)
+ : ancillary_stores(host), IClassFixture;
diff --git a/src/Bobcat/Bobcat.CritterStackScenario.Tests/critter_stack_scenario.cs b/src/Bobcat/Bobcat.CritterStackScenario.Tests/critter_stack_scenario.cs
new file mode 100644
index 000000000..8162ebae9
--- /dev/null
+++ b/src/Bobcat/Bobcat.CritterStackScenario.Tests/critter_stack_scenario.cs
@@ -0,0 +1,1608 @@
+using Bobcat.CritterStack;
+using Bobcat.Engine;
+using Bobcat.Monitoring;
+using Bobcat.Engine.Verification;
+using JasperFx.Events.Tags;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Shouldly;
+
+namespace Bobcat.CritterStack.ScenarioTests;
+
+// bobcat#471, phase 1: every member of the store-only vocabulary, on Marten and on Fisher, with no message
+// bus registered anywhere. Each test runs once per store through the two derived classes at the bottom.
+
+public abstract class critter_stack_scenario(THost store) where THost : StoreHost
+{
+ private CritterStackScenario newScenario()
+ {
+ Assert.SkipUnless(store.IsAvailable, store.SkipReason);
+ return new CritterStackScenario(store.Host);
+ }
+
+ // A store-only act: whatever an application does, here an append through the store's own abstractions
+ private static Func appending(Type aggregate, Guid id, params object[] events)
+ => services => EventStoreAuthoring.AppendAsync(services.EventStore(), aggregate, id, events);
+
+ private static Func appending(Guid id, params object[] events)
+ => appending(typeof(Wallet), id, events);
+
+ // ---- arrange ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task givens_append_and_read_as_the_wolverine_vocabulary_reads()
+ {
+ var scenario = newScenario();
+ var id = Guid.NewGuid();
+ var other = Guid.NewGuid();
+
+ var recording = await Recordings.RecordAsync(async () =>
+ {
+ await scenario.GivenEvents(id, new WalletOpened(id, "Ann"), new WalletCredited(id, 10));
+ await scenario.GivenEventsOn(other, new WalletOpened(other, "Bea"));
+ await scenario.GivenNoEventsFor(Guid.NewGuid());
+ });
+
+ recording.GatheredFailures().ShouldBeNull();
+ recording.Steps.Select(x => x.Keyword).ShouldBe(["Given", "And", "And"]);
+ recording.Steps[0].Text.ShouldStartWith("the Wallet stream has already recorded WalletOpened(");
+ // bobcat#640: a second stream's name is marked as an alias, so it cannot read as the type twice
+ recording.Steps[1].Text.ShouldStartWith("Wallet #Wallet2 has already recorded WalletOpened(");
+ recording.Steps[2].Text.ShouldEndWith("has no events yet");
+
+ (await scenario.TheAggregate(id))!.Balance.ShouldBe(10);
+ (await scenario.TheAggregate(other))!.Owner.ShouldBe("Bea");
+ (await scenario.TheStreamVersion(id)).ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task a_strong_typed_id_and_a_partial_object_arrange_a_stream()
+ {
+ var scenario = newScenario();
+ var id = new WalletId(Guid.NewGuid());
+
+ await scenario.GivenEvents(id,
+ new WalletOpened(id.Value, "Cy"),
+ Specifications.Specify