Skip to content

Consume the lifted JasperFx.Events ProjectionScenario #404

Description

@jeremydmiller

Note

Reframed 2026-08-03. The original plan — hand-write the 15 missing overloads — is obsolete. ProjectionScenario has been lifted into JasperFx.Events so both stores share one implementation, which closes this gap without porting anything. The original gap analysis is preserved at the bottom; it is still the best record of what diverged.

What to do now

Consume JasperFx.Events.TestSupport.ProjectionScenario<TOperations, TQuerySession> (JasperFx/jasperfx#616, shipping in JasperFx.Events 2.38.0) instead of maintaining Polecat's own port.

Delete Polecat's seven Events/TestSupport/ files and replace them with one subclass closing the generic pair over Polecat's session types. Marten's equivalent adoption is JasperFx/marten#5133 — a ~70-line class, −692/+51 lines — and is the reference to copy.

The subclass implements seven abstract seam members, all one-liners:

Member What it does
DeleteExistingDataAsync(ct) Wipe event + projection storage
HasAnyAsyncProjections Whether to stand up a daemon
BuildDaemonAsync(tenantId) Build the projection daemon
OpenSession(tenantId) Open a writable session
SaveChangesAsync(session, ct) Commit (no shared interface declares it)
EventsFor(session) Session's IEventOperations
LoadDocumentAsync<T>(session, object id, ct) Load by id, dispatching on the id's runtime type

This should be nearly free for Polecat, because PolecatComplianceFixture already implements every one of these — the seam was deliberately shaped to match EventStoreComplianceFixture, including the object-id load dispatch. Lift the bodies straight across.

What the lift changes about the API

Worth knowing before adopting, since Polecat's port matches the old Marten shape:

  • The 15 missing overloads simply exist now, inherited. Nothing to write.
  • Append / StartStream return void, not StreamAction — the old return value was a throwaway disconnected from the queued operation. The StartStream overloads that generate their own id return that Guid.
  • DocumentShouldExist<T> / DocumentShouldNotExist<T> take object ids — one overload each, not four per identity type. Typed call sites still compile.
  • DoNotDeleteExistingDataDeleteExistingData (default true); no more double negative.
  • Assertion failures throw ProjectionScenarioAssertionException; a failed action now stops the scenario instead of letting later steps run against unintended state; a scenario can only execute once; Timeout is configurable and the CancellationToken is honored.
  • ExecuteExecuteAsync.

Background on why the shape changed first: JasperFx/marten#5127 and JasperFx/marten#5132.

Blocked on

JasperFx/jasperfx#616 merging and JasperFx.Events 2.38.0 publishing.


Original issue (2026-08-02): the 15-overload gap analysis

Polecat/Events/TestSupport/ProjectionScenario mirrors Marten's design file-for-file (same seven files, same type names, same entry point store.Advanced.EventProjectionScenario(...)), but the event-operations half is implemented much more shallowly. A test written against Marten's scenario API frequently will not compile against Polecat's.

Measured against Marten/Events/TestSupport/ at marten master and Polecat/Events/TestSupport/ at polecat main.

Assertions: already at parity

Nine members each, identical signatures — AssertAgainstProjectedData, DocumentShouldExist<T> and DocumentShouldNotExist<T> over Guid/int/long/string. The only difference is the generic constraint (where T : notnull in Marten, where T : class in Polecat), which is a deliberate divergence, not a gap.

Event operations: 11 members vs Marten's 28

Present in Marten, missing in Polecat:

Every IEnumerable<object> overload — Polecat is params object[] only:

StreamAction Append(Guid stream, IEnumerable<object> events);
StreamAction Append(string stream, IEnumerable<object> events);
StreamAction Append(string stream, long expectedVersion, IEnumerable<object> events);
StreamAction StartStream(IEnumerable<object> events);
StreamAction StartStream(Guid id, IEnumerable<object> events);
StreamAction StartStream(string streamKey, IEnumerable<object> events);
StreamAction StartStream<TAggregate>(IEnumerable<object> events);
StreamAction StartStream<TAggregate>(string streamKey, IEnumerable<object> events);

This is the one that bites in practice: a caller with a List<object> of events has to spread it at every call site.

The Type-argument aggregate overloads — for code that has a Type rather than a generic parameter:

StreamAction StartStream(Type aggregateType, IEnumerable<object> events);
StreamAction StartStream(Type aggregateType, params object[] events);
StreamAction StartStream(Type aggregateType, Guid id, IEnumerable<object> events);
StreamAction StartStream(Type aggregateType, Guid id, params object[] events);
StreamAction StartStream(Type aggregateType, string streamKey, IEnumerable<object> events);
StreamAction StartStream(Type aggregateType, string streamKey, params object[] events);

The no-identity generic overload:

StreamAction StartStream<TAggregate>(params object[] events);   // server-assigned id

Stream compactingstruck after reading Marten's implementation. Marten's two CompactStreamAsync<T> overloads exist only to satisfy IEventOperations and both bodies are:

throw new NotSupportedException();

So this is not a capability Polecat is missing; Polecat simply does not declare the stubs. Nothing to do here, and arguably Marten's shape is the worse of the two. Excluded from the count below.

Suggested scope

  1. Add the IEnumerable<object> overloads — mechanical, and the highest value per line.
  2. Add the Type-argument and no-identity StartStream overloads.

That is the whole gap: 15 overloads, all of them shape rather than capability.

Related

There is a standing question of whether ProjectionScenario should be lifted into JasperFx.Events generic on <TOperations, TQuerySession> instead of maintained twice — see the companion Marten issue. Closing this gap first is not wasted work either way: it establishes what the shared surface would have to cover, and it is useful immediately regardless of whether the lift happens.

Found by

Compliance wave 2 (marten#5118), while checking whether the two Events/TestSupport/ trees are duplicates. They are not — same design, different depth.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions