Skip to main content
net10.0net9.0

Shared behavioral compliance test suites for Critter Stack event stores. Ships as C# source, not a compiled assembly.

Published
Sep 6, 2026
1mo ago
Total Downloads
17.6K
17,645

Install

dotnet add package JasperFx.Events.ComplianceTests --version 2.65.0
ProductVersions
.NET
net10.0net9.0

README

JasperFx.Events.ComplianceTests

Shared behavioral compliance test suites for Critter Stack event stores β€” the same event sourcing expectations asserted against Marten (PostgreSQL), Polecat (SQL Server), and any future store built on JasperFx.Events.

This package ships C# source, not a compiled assembly. That is forced, not a preference: JasperFx.Events.SourceGenerator emits aggregate dispatchers per consuming assembly and binds each product's own session type, so the shared aggregates have to be compiled inside each consumer. Compiling in the consumer also sidesteps package-version skew and LangVersion/ImplicitUsings differences between the repos.

Using it

Reference the package from a test project that already has xunit v3 and Shouldly, then supply three things.

1. Six global aliases naming your store's own types. The shared suites declare aggregates and projections at file scope, so they cannot reach the <TOperations, TQuerySession> pair the suite classes are generic over. The source generator resolves these by type name, so aliases are enough:

global using ComplianceQuerySession = Marten.IQuerySession;
global using ComplianceOperations = Marten.IDocumentOperations;
global using ComplianceEventProjection = Marten.Events.Projections.EventProjection;
global using ComplianceStringPartyProjectionBase =
    Marten.Events.Aggregation.SingleStreamProjection<
        JasperFx.Events.ComplianceTests.StringQuestParty, string>;
global using ComplianceMultiStreamProjectionBase =
    Marten.Events.Projections.MultiStreamProjection<
        JasperFx.Events.ComplianceTests.ComplianceDepartment, string>;
global using ComplianceWatchtowerProjectionBase =
    Marten.Events.Aggregation.SingleStreamProjection<
        JasperFx.Events.ComplianceTests.ComplianceWatchtower, System.Guid>;

ComplianceQuerySession binds the EvolveAsync(IEvent, …) convention on the self-aggregating fixtures; the next two bind the EventProjection suites to your product's own projection base and writable session. The last three are closed generics, because the single stream and multi stream projection bases are generic over both the document and its identity β€” they bind the string-identity, multi-stream and side-effect suites' custom projections to your product's SingleStreamProjection<TDoc, TId> and MultiStreamProjection<TDoc, TId>.

2. A concrete fixture closing EventStoreComplianceFixture<TOperations, TQuerySession> over your store's session pair. Everything portable in the suites runs through the shared JasperFx surfaces (IEventStoreOperations, IEventRegistry, IProjectionDaemon); the fixture only has to supply what no shared interface declares β€” store construction from a ComplianceStoreConfig, session acquisition, SaveChangesAsync, document load-back, batched DCB queries, and teardown.

3. Three partial classes, for the suites whose shared type cannot be reached by an alias.

FlatTableProjectionCompliance is the first: every product's flat-table projection base takes constructor arguments describing where the table lives, and those signatures genuinely differ, so no single base(...) call satisfies all of them. Declaring the primary key column is per-product for the same reason β€” that API hangs off each dialect's own Table type. The library owns the table name, the projection name and every event mapping; a consumer supplies the rest:

namespace JasperFx.Events.ComplianceTests;

public partial class ComplianceFlatTableProjection : FlatTableProjection
{
    public ComplianceFlatTableProjection() : base(TableName, SchemaNameSource.DocumentSchema)
    {
        Table.AddColumn<Guid>("id").AsPrimaryKey();   // your dialect's column API
        ConfigureMappings();                          // everything portable
    }
}

If your base takes a literal schema name rather than resolving the store's, pass ComplianceFlatTableProjection.SchemaName β€” the suite configures its store with the same constant, and the two have to agree or the projection writes into a table the suite is not reading.

SubscriptionCompliance is the second, for a subtler reason. Both products declare ISubscription with an identical member β€” Task<IChangeListener> ProcessEventsAsync(EventRange, ISubscriptionController, IDocumentOperations, CancellationToken) β€” but IChangeListener is a per-product type, so the signature cannot be written once. The library owns the recording, the waiting and the subscription name; a consumer supplies only the interface implementation:

namespace JasperFx.Events.ComplianceTests;

public partial class ComplianceSubscription : ISubscription   // your product's ISubscription
{
    public Task<IChangeListener> ProcessEventsAsync(EventRange page, ISubscriptionController controller,
        IDocumentOperations operations, CancellationToken cancellationToken)
    {
        Record(page.Events);

        // The two guarantees SubscriptionCompliance pins beyond delivery: writes through the
        // supplied session, and the listener the store is supposed to call after the commit.
        foreach (var note in NotesFor(page)) operations.Store(note);

        return Task.FromResult<IChangeListener>(new Listener(this));
    }

    private sealed class Listener(ComplianceSubscription subscription) : IChangeListener
    {
        public Task AfterCommitAsync(IDocumentSession session, IChangeSet commit, CancellationToken token)
            => subscription.RecordCommitAsync();

        public Task BeforeCommitAsync(IDocumentSession session, IChangeSet commit, CancellationToken token)
            => Task.CompletedTask;
    }
}

Your registrar's Subscribe should pin the name to ComplianceSubscription.SubscriptionName; progression is keyed on it and the products disagree on what an unnamed subscription defaults to. It should also replay subscription.IncludedEventTypes onto whatever its own registration carries filters on β€” every product wraps a bare ISubscription in its own SubscriptionBase, and it is the wrapper the daemon reads filters from, so a list declared on the shared object reaches nothing on its own. That one is gated (SupportsSubscriptionEventFilters, default false); the store-side note and the listener are not, because a store that hands out a session it never commits, or drops the listener it was handed, passes every delivery fact in the suite.

RecordingMessageOutbox β€” with its companion RecordingMessageBatch β€” is the third, and it is the same shape as the second one more time. Every product declares its own IMessageOutbox and IMessageBatch, and here even the members differ: Marten's IMessageBatch : IMessageSink, IChangeListener takes (IDocumentSession, IChangeSet, CancellationToken) on both commit hooks, while Polecat's and Fisher's take a bare CancellationToken. The library owns the recording, the ordering, the commit probe and the shared IMessageSink half; a consumer supplies four one-line members. Only ProjectionSideEffectCompliance needs these, so a store that has no message outbox simply leaves SupportsMessageOutbox false and never writes them.

namespace JasperFx.Events.ComplianceTests;

public partial class RecordingMessageOutbox : IMessageOutbox   // your product's IMessageOutbox
{
    public ValueTask<IMessageBatch> CreateBatch(DocumentSessionBase session) => new(NewBatch());
}

public partial class RecordingMessageBatch : IMessageBatch     // your product's IMessageBatch
{
    public Task BeforeCommitAsync(IDocumentSession session, IChangeSet commit, CancellationToken token)
        => RecordBeforeCommitAsync();

    public Task AfterCommitAsync(IDocumentSession session, IChangeSet commit, CancellationToken token)
        => RecordAfterCommitAsync();
}

Then enroll each suite with an empty subclass:

public class dcb_tag_query_and_consistency_compliance
    : DcbTagQueryAndConsistencyCompliance<MyComplianceFixture, IDocumentOperations, IQuerySession>;

Opt-in capability suites

Several suites cover capabilities that are opt-in rather than part of the baseline event contract, and their seam members on IComplianceStoreRegistrar carry throwing defaults: a store that has not implemented the capability does not enroll, never reaches the member, and keeps compiling.

AggregateWriteCacheCompliance (jasperfx#674) is the oldest of them. IAggregateWriteCache is a baseline-only cache β€” the stream version and every event after the cached version are still read on every fetch β€” so the suite's job is to prove that turning it on is unobservable except in latency, including when the cached baseline is wrong. Note the shape of the load-bearing assertion: every correctness fact about caching is vacuously true of a store that ignored the opt-in entirely, so the suite supplies its own RecordingAggregateWriteCache and asserts a nonzero hit count. Same reasoning as the gzipped serializer in BinaryEventSerializationCompliance.

CompositeProjectionCompliance (jasperfx#725) is opt-in through IComplianceStoreRegistrar.AddCompositeProjection. It needs a seam because a composite cannot be constructed by a suite at all: every product subclasses the shared CompositeProjection<TOperations,TQuerySession> and keeps the constructor internal, since it needs the store's options, so a composite only comes into being through that product's own Projections.CompositeProjectionFor(name, configure) β€” whose configure parameter is typed to the product's own subclass. The implementation is a forward plus a small adapter, because the calls are identical across the products even where the types are not; all three expose Snapshot<T>(int stageNumber) on their composite, and IComplianceCompositeBuilder declares its own void-returning member only because Marten's returns a mapping expression while Polecat's and Fisher's return void:

public void AddCompositeProjection(string name, Action<IComplianceCompositeBuilder> configure)
    => _options.Projections.CompositeProjectionFor(name, c => configure(new Builder(c)));

Its load-bearing fact is the rebuild one, and the members are additive for that reason: a store that replayed the stream over surviving rows instead of tearing the member down reads back exactly doubled, which a last-write-wins aggregate would hide. Two of the three products already carry a local test named for that class of bug β€” Bug_439_composite_member_teardown and composite_member_teardown β€” which is what marks the behaviour as shared rather than product-owned.

DcbHasTagLinqCompliance (jasperfx#755) is opt-in through fixture seam members rather than the registrar. It pins the IEvent.HasTag<TTag>(value) LINQ marker β€” a DCB tag predicate composing with ordinary event predicates in one Where() over the raw-event query β€” which no shared surface can reach twice over: QueryAllRawEvents() is deliberately off IQueryEventStore, and both products' LINQ parsers recognize HasTag by the method's declaring type, so an expression written in shared source would carry the wrong MethodInfo and never be recognized. Two seam members with throwing defaults close the gap β€” HasTagFilter<TTag> builds the store's own marker predicate (one line: e => e.HasTag(value)), and QueryRawEventsAsync executes exactly one predicate through one Where() β€” gated on SupportsHasTagLinqPredicates (default false β€” implement the pair, flip the flag). The suite ANDs predicates into a single tree itself, so the parser meets HasTag as an AndAlso operand rather than a lone filter. This stays on the DCB-tag side of the general-LINQ exclusion below: the suite pins tag predicate semantics (tag matching, AND composition, tenant scoping, unregistered-tag throw), never a provider operator set. Marten's hstore-mode fact stayed behind as one engine's storage layout.

SingleTenantedEventSlicingCompliance (jasperfx#724) is opt-in for a different reason: it needs no registrar seam at all, but the precondition it constructs is unusual enough that a store should adopt it knowingly. On a single-tenanted store, events whose tenant_id values disagree must still fold into one aggregate β€” wolverine#2053 / marten#4085 β€” and only the async daemon ever got that wrong, so the suite drives the daemon rather than asserting on a live aggregate. Note its guard: it stamps disagreeing tenant ids through IEvent.TenantId, then reads the events back and skips if the store normalised them away, because an unmixed store satisfies the assertion for a reason that has nothing to do with the behaviour under test. Vacuous green is worse than a skip, since only one of the two is visible.

AggregateToManyCompliance and AggregateToLinqOperatorCompliance (jasperfx#754) are opt-in through fixture seam members rather than the registrar: the AggregateToAsync / AggregateToManyAsync operators ride on each product's raw-event LINQ query (QueryAllRawEvents()), which is deliberately not part of the shared IQueryEventStore contract, so AggregateEventsToAsync / AggregateEventsToManyAsync carry throwing defaults and the suites gate on SupportsAggregateToLinqOperators (default false β€” implement the pair, flip the flag). The seam is one optional predicate over the shared IEvent applied in a single Where(), then the product's own terminator β€” deliberately no ordering or paging, for the same reason QueryTableAsync is predicate-free: this must not grow into pinning a query provider's operator set. Enrolling also takes two more closed-generic aliases beside ComplianceMultiStreamProjectionBase (ComplianceBalanceProjectionBase, ComplianceMemberLoyaltyProjectionBase β€” see Local/ComplianceAggregateToManyPlaceholders.cs for the exact spelling), because the many-suite's whole point is that the operator drives the registered projection β€” identity routing, a session-reading custom grouper, ShouldDelete β€” rather than reimplementing the fold inline, and its projections are registered Async with the daemon never started so every asserted aggregate can only have come from the live fold.

UpcastingCompliance (jasperfx#752) is opt-in twice over, because it is the first suite written ahead of any store implementing the behavior. The seam member β€” IComplianceStoreRegistrar.Upcast(UpcastTransformation) β€” carries the usual throwing default, and the suite is additionally gated on SupportsUpcasting, the one capability flag that defaults to false: an enrolled store skips every fact (including configuration, so the throwing default is never reached) until it routes its read paths through the shared JasperFx.Events.Upcasting.UpcastingRegistry and implements IUpcastPayload over its own reader and serializer, then flips the gate and works the facts to green β€” the same declare-the-surface-then-implement ordering as SupportsFlatTableProjections. Most facts write rows through a "legacy" store configuration that has never heard of the upcasts, then read them back through the migrated configuration, because that is what a real schema migration looks like; the one single-store fact pins the marten#4680 authority rule β€” a typed append of the old CLR type, whose stored dotnet-type hint would otherwise swap the mapping back, must still read through the upcaster.

NaturalKeyCompliance (jasperfx#764) is opt-in through SupportsNaturalKeys (default false), and is the one opt-in suite that adds no seam member at all β€” worth stating, because the natural key cluster looks like it should need one. It does not: the [NaturalKey] / [NaturalKeySource] attributes and NaturalKeyDefinition already live in JasperFx.Events.Aggregation, discovery already runs inside the shared JasperFxAggregationProjectionBase every product's aggregation projection derives from, and the fetch triple (FetchForWriting<T,TId>, FetchForExclusiveWriting<T,TId>, FetchLatest<T,TId>) is already on IEventStoreOperations. Registration is the existing Snapshot<T>(lifecycle) β€” what all three products spell, whether the user writes Marten's Snapshot<T>(Inline) or Polecat's Add<SingleStreamProjection<T,TId>>(Inline) β€” plus RegisterValueType<T>() for the wrapper. Only the storage half varies, and that is behavior, so one gate carries it.

Its load-bearing facts are the rebuild pair (marten#4788 / marten#4966 / polecat#259): the lookup is maintained by the inline append path, which drives off newly-appended stream actions, so a daemon rebuild replayed events without ever repopulating the table and every natural key fetch missed afterwards. Those facts register the snapshot Async rather than Inline, and that is the whole trick β€” under an async registration the inline path never runs, so a lookup row can only have come from the daemon. The obvious alternative (append inline, rebuild, fetch) passes vacuously on any store whose rebuild leaves the table untouched, the same failure mode AggregateWriteCacheCompliance counts cache hits to avoid. The products' own versions delete the table with raw SQL and count rows; the table name is per-product, so the async registration buys the same guarantee portably.

Uniqueness of a key across streams was the one open product question, and it is settled (jasperfx#764): a natural key already mapped to a live stream is refused to a second claimant. Fisher refused; Polecat's MERGE repointed the key at the newcomer, which leaves the original stream in place but unreachable by the identifier it was created with, and reports nothing. Refusing is the contract, a_second_stream_cannot_claim_a_live_natural_key pins it, and Polecat carries the behavioral change (polecat#549). The exception is the shared JasperFx.Events.DuplicateNaturalKeyException, lifted from Fisher's copy alongside the jasperfx#751 exception types with its message adopted verbatim.

That fact asserts two things, and the second is the one that does the work: the throw, and that the original stream's mapping still resolves afterwards. An implementation could throw and still have written the row β€” repoint, then fail the transaction, or write the mapping through a path the failure does not roll back β€” so asserting only the exception would pass on a repointing store. The mapping is read back by stream id rather than by nullness, for the same reason.

The cluster still contains more genuine disagreement than its matching test names suggest, and three things stayed behind. The miss on FetchForWriting is three products and two contracts β€” Marten returns a null aggregate at version 0, Polecat throws InvalidOperationException, Fisher throws UnknownNaturalKeyException β€” so every "does not resolve" assertion in the suite is written against FetchLatest, the one miss Marten and Polecat spell identically. Rollback of the key row is real and shared but needs a poisoned unit of work, which no shared surface can produce on demand. And the "live lifecycle" pair shares a name across Marten and Polecat but not a precondition β€” Marten registers LiveStreamAggregation<T>(), Polecat's same-named tests register Inline with a comment saying the inline registration is what creates the lookup at all β€” so the shared precondition does not exist yet.

One thing consumers should expect: the source generator emits an assembly-level NaturalKeyAggregateAttribute for every type carrying a [NaturalKey] property, so a store may auto-register these aggregates and their lookup infrastructure at startup merely because the suite was compiled in β€” including ComplianceNaturalKeyOrderByKey, a string-identity aggregate, into Guid-identity stores. Unlike the DCB suite's CourseLoad, which is discovered lazily and can sit unregistered, this is eager. Watch for it at store construction rather than assuming the gate defends against it.

ProjectionSideEffectCompliance (jasperfx#763) covers RaiseSideEffects, which is genuinely shared β€” declared on JasperFxAggregationProjectionBase, drained by the shared single- and multi-stream bases into IProjectionBatch.PublishMessageAsync and EventSlice.BuildOperations β€” while the half underneath it is not. Building the append operations and handing out a message batch is each store's own, and that half has shipped stubbed empty twice (fisher#61, polecat#420), dropping every raised event with no error and no log. A projection whose side effects go nowhere passes every other suite here, which is the whole argument for this one.

Its cost is the highest of any suite in the library, and each piece is forced. One more closed generic alias beside ComplianceStringPartyProjectionBase (ComplianceWatchtowerProjectionBase β€” see Local/ComplianceSideEffectProjectionPlaceholder.cs for the exact spelling), because the suite's projection has to override RaiseSideEffects and so must derive from the product's own SingleStreamProjection<TDoc, TId>. And two more per-consumer partials, RecordingMessageOutbox and RecordingMessageBatch, for the same reason as ComplianceSubscription: IMessageOutbox and IMessageBatch are per-product types whose members genuinely differ β€” Marten declares IMessageBatch : IMessageSink, IChangeListener, so its hooks take (IDocumentSession, IChangeSet, CancellationToken), while Polecat's and Fisher's take a bare CancellationToken. The library owns the recording, the ordering and the probe; a consumer writes four one-line members. The IMessageSink half β€” PublishAsync<T>(T, string) β€” is shared and stays in the library.

The load-bearing shape is the same as AggregateWriteCacheCompliance's: the suite supplies the outbox so it can assert a nonzero publish count. "The document came out right" and "nothing threw" are both vacuously true of a store that discarded the side effects, and the rebuild fact is where that matters most β€” "no messages were published during the rebuild" is trivially satisfied by a store that published none at all, so the pre-rebuild count is required to be positive first. Likewise the suppression fact asserts on the stream, not the aggregate: a rebuild that re-raised would append the audit event again on every rebuild, and the projected document is idempotent under that and looks identical either way.

Two seams, both gated, both default false. SupportsMessageOutbox gates the outbox facts, and it is a gate rather than a plain non-enrollment because installing the outbox drives store construction through IComplianceStoreRegistrar.UseMessageOutbox β€” so the suite keeps the outbox out of its standard configuration and reconfigures only after the gate passes, and the raised-event facts run either way. SupportsCommitVisibilityProbe gates the single fact that proves the commit boundary rather than the hook order, because that fact deliberately reads a row an open transaction is writing: a snapshot reader answers, a lock-based one blocks, and a probe that blocks until the commit deadlocks against the hook holding the commit open. Hook order alone does not prove the boundary β€” the two would fire in that order even if both ran before the commit.

Capability gates

Where a store genuinely cannot support a behavior, override the virtual bool Supports... flags on the fixture; the affected tests skip rather than fail. Gates are meant to be temporary and tracked β€” a suite failing on your store is usually a product bug, not a test to soften.

Local dev loop

Both current consumers accept a ComplianceSourceDir property that swaps the published suites for a working copy, so a new wave can be validated against a real store before the JasperFx release:

dotnet test src/EventSourcingTests/EventSourcingTests.csproj -f net9.0 \
    -p:ComplianceSourceDir=/path/to/jasperfx/src/JasperFx.Events.ComplianceTests

What is in scope

The library asserts behavior that every JasperFx.Events store owes its users, reached through the shared interfaces (IEventStoreOperations, IQueryEventStore, IEventRegistry, IEventStore, IProjectionDaemon). Covered today:

Area Suite
Self-aggregating EvolveAsync conventions SelfAggregatingEvolveCompliance
DCB tag queries and consistency DcbTagQueryAndConsistencyCompliance
AssignTagWhere AssignTagWhereCompliance
DCB HasTag predicates in event LINQ queries DcbHasTagLinqCompliance
Async daemon smoke + rebuild AsyncDaemonCompliance
Aggregate type auto-discovery AutoDiscoveredAggregateCompliance
EventProjection registration and enrichment EventProjectionRegistrationCompliance, EventProjectionEnrichmentCompliance
Rebuild concurrency cap resolution RebuildConcurrencyCapCompliance
Session correlation / causation from Activity ActivityCorrelationCompliance
String stream identity, single stream projections StringIdentitySingleStreamCompliance
Write handles and stream concurrency FetchForWritingCompliance
The second-level FetchForWriting snapshot cache AggregateWriteCacheCompliance
Stream reads, time travel, stream state StreamReadCompliance
The IEvent envelope contract EventMetadataCompliance
Live aggregation, including last-known LiveAggregationCompliance
FetchLatest / ProjectLatest across lifecycles FetchLatestCompliance
Archiving a stream and its consequences StreamArchivingCompliance
The event store explorer surface EventStoreExplorerCompliance
Flat-table event projections FlatTableProjectionCompliance
String stream identity, read and write surface StringStreamIdentityCompliance
Multi-stream projection grouping and fan-out MultiStreamProjectionCompliance
Snapshot lifecycle equivalence (Inline / Async / Live) SnapshotLifecycleCompliance
Strong-typed identifiers on aggregates StrongTypedIdentityCompliance
Stream compacting into a Compacted<T> snapshot StreamCompactingCompliance
Batch data masking of stored events EventDataMaskingCompliance
Projection rebuild and catch-up semantics RebuildAndCatchUpCompliance
The projection error path and dead letters DeadLetterCompliance
Conjoined (per-tenant) event tenancy ConjoinedEventTenancyCompliance
The cross-stream event query (QueryEventsAsync) EventQueryCompliance
The stream-state queryable (QueryStreamStates) StreamStateQueryCompliance
Subscriptions β€” delivery, rewind, filters, session writes, the change listener SubscriptionCompliance
Single-tenanted slicing of disagreeing tenant ids SingleTenantedEventSlicingCompliance
Composite projections β€” staging and member teardown CompositeProjectionCompliance
AggregateToAsync over an event query AggregateToLinqOperatorCompliance
AggregateToManyAsync through a registered projection AggregateToManyCompliance
Event upcasting β€” old stored schemas read as the current types UpcastingCompliance
Projection RaiseSideEffects β€” raised events and published messages ProjectionSideEffectCompliance
A reachable IProjectionCoordinator over the documented registration ProjectionCoordinatorCompliance
Natural keys β€” reaching a stream by its business identifier, and its uniqueness across streams NaturalKeyCompliance
AlwaysEnforceConsistency β€” a version check with no events appended AlwaysEnforceConsistencyCompliance
The stream-fetch query plans, standalone and batched StreamQueryPlanCompliance
The ProjectionScenario test harness ProjectionScenarioCompliance

Pinning a shared harness (jasperfx#769)

ProjectionScenarioCompliance is the odd one out, and worth reading before adding anything like it. Everything else here pins shared behaviour implemented separately by each store. ProjectionScenario<TOperations, TQuerySession> is shared code β€” it lives in JasperFx.Events/TestSupport, and every product subclasses it over a five-member seam (DeleteExistingDataAsync, HasAnyAsyncProjections, BuildDaemonAsync, OpenSession, LoadDocumentAsync). One copy of the sequencing logic exists, so the failures worth catching are the ones a store introduces underneath it: a store can subclass the harness perfectly, wire one of those five wrongly, and leave every other suite in this library green. Every fact in the suite is aimed at one of the five, and asserts observable store state rather than the harness's own bookkeeping.

That is also why the fixture has two seam members rather than one. RunProjectionScenarioAsync must forward to the store's own documented entry point β€” Marten's and Polecat's Advanced.EventProjectionScenario, Fisher's EventProjectionScenarioAsync β€” never re-implement it. All three spell that entry point as three lines (construct, configure, ExecuteAsync), and a fixture that inlined those three lines would pass the whole suite while the store's advertised entry point was missing or pointed at a different store. CreateProjectionScenario exists only for the facts the run entry point structurally cannot reach: a scenario's steps are consumed by its first run, so a_scenario_cannot_be_executed_twice needs a handle the entry point constructs and throws away.

Two things it deliberately does not cover. The composite-projection form of the clean-slate fact (marten#5169 β€” the wipe list came from StorageTypes rather than PublishedTypes(), so a composite read side wiped nothing and every scenario after the first ran against the previous one's documents while its events were gone) would force every store enrolling this suite to support composites, which are opt-in through an unrelated seam; the portable half of that fact is asserted and the rest stays with the product. Multi-tenanted scenarios are out for the same shape of reason: pairing this gate with the conjoined-tenancy one would make the most valuable facts skip on a store that has one but not the other.

The empty unit of work (jasperfx#762)

AlwaysEnforceConsistencyCompliance is a separate suite rather than more facts on FetchForWritingCompliance, and the reason generalises. Every concurrency assertion in that suite appends an event first, and appending is what makes the ordinary version check fire β€” so a store that ignored IEventStream<T>.AlwaysEnforceConsistency completely passes all of it. The flag exists for the case that suite structurally cannot reach: a decider that reads a stream, decides to emit nothing, and still needs to know its read was not stale. A behaviour whose whole point is what happens when nothing happens needs its own suite, because the natural setup for every neighbouring fact destroys it.

StreamQueryPlanCompliance and the unarchive facts on StreamArchivingCompliance are both opt-in through Supports flags defaulting false, for the ordinary reason: each store declares its own IQueryPlan<T> / IBatchQueryPlan<T> and its own QueryByPlan route (so the plan types cannot be shared, only the results), and UnArchiveStream is on Polecat's own IEventOperations with no Marten equivalent, so it is not on IEventStoreOperations and cannot be. Note the query plan suite's batched axis: a plan implements two interfaces with two separate implementations β€” standalone it owns the whole command, batched it contributes a fragment to someone else's β€” so every fact runs twice, and the version cap, the one parameter that has to survive into the batched fragment's own SQL, is where the two paths are most likely to drift.

Identity-less boundary aggregates (jasperfx#718)

DcbTagQueryAndConsistencyCompliance carries one aggregate β€” CourseLoad β€” with no Id and no [AggregateIdentity], marked [BoundaryAggregate]. It is there because every other DCB aggregate in the suite happens to have an identity, so a store could require one for a boundary aggregate and still pass the whole suite. Both SQL stores did: Fisher threw from its own identity resolution (fisher#135), Polecat from DocumentMapping's constructor (polecat#521), despite the marker being the documented cross-stack answer.

Its two facts fold with matching events present, and that is load-bearing rather than incidental. FetchForWritingByTags only resolves the aggregator when the query finds events, so a boundary over an empty result β€” the ordinary "this must not exist yet" assertion β€” succeeds on a store that cannot fold the type at all.

The type is declared in the shared source rather than as a per-consumer partial, which works because the package ships as source: the attribute lands on the aggregate in the consumer's own compilation, which is both the compilation the generator emits [GeneratedEvolver] into and the assembly the runtime scans as typeof(TDoc).Assembly. It is deliberately never registered β€” no snapshot, no live aggregation β€” since a DCB aggregate is discovered lazily on first use, and registering it eagerly would move the failure to store construction, where it would take every other fact in the suite down with it.

A reachable IProjectionCoordinator (jasperfx#732)

ProjectionCoordinatorCompliance is the one suite that builds a real application host, because what it pins cannot be observed any other way: every other daemon suite drives a daemon the fixture constructed by hand, which says nothing about whether the store's documented DI registration produces a coordinator application code can reach. Fisher shipped exactly that gap (fisher#138) β€” AddAsyncDaemon registered only an IHostedService over an internal class implementing nothing else, so GetRequiredService<IProjectionCoordinator>() threw and the GetServices<IHostedService>().OfType<IProjectionCoordinator>() fallback found nothing β€” and passed all 37 suites the whole time. Third instance of the jasperfx#700 / jasperfx#718 pattern, which is why it is a suite rather than a third fix.

The seam is one fixture member plus the small IComplianceCoordinatorHost<TOperations> wrapper: build and start a host that registers the store the documented way β€” the product's AddXxx(...) plus its documented async daemon registration β€” replaying the suite's ComplianceStoreConfig onto the hosted store, over the same database as the fixture's own store. Services and OpenSession() reach the hosted store; DisposeAsync stops the host.

The member carries a throwing default so a consumer keeps compiling across the bump, but there is deliberately no Supports flag and no skip for the core facts: a store that enrolls the suite without implementing the seam fails every fact rather than skipping, because a skippable registration check recreates the silent gap the suite exists to close. Two of the facts carry the weight. The same-instance fact is a correctness matter, not tidiness β€” two registrations of one coordinator are two daemons over one database, which for a single-writer store is two writers contending for one lock. And the pause/resume fact is the one that would have caught fisher#138's real bug: its StartAsync only started daemons for databases not already running, so a naive ResumeAsync restarted nothing β€” the suite's first append-and-wait round before the pause proves the daemon was genuinely running, so a post-resume timeout indicts resume rather than startup.

The one gated fact is the ancillary store's marker-typed IProjectionCoordinator<T>, behind SupportsAncillaryCoordinators (default false), because not every store has ancillary registration at all. A store that flips it also implements AncillaryCoordinatorFrom β€” a fixture member because ancillary marker types cannot be shared: every product constrains them to its own store interface, so only the fixture can name one. The default host registers only the primary store, and that is load-bearing: the hosted-service walk asserts exactly one coordinator, so the ancillary store only joins the host the ancillary fact asks for (includeAncillaryStore: true).

Subscriptions beyond delivery (jasperfx#768)

SubscriptionCompliance started as six facts about delivery β€” the right events, in sequence order, from every stream, once each β€” which is the easy half. What a subscription is for went unpinned, and two of the gaps were the kind that pass in silence.

Writes through the supplied session are committed with the batch. The session is handed over precisely so a subscription's writes land in the batch's transaction alongside the progression row; that is what makes them exactly-once against the store's own database and what makes a subscription a peer of a projection rather than a webhook. A store that hands out a session it never commits satisfies all six delivery facts. And the change listener returned by ProcessEventsAsync is called, after the commit. A store that drops the return value passes all six as well. Neither is gated, for the reason ProjectionCoordinatorCompliance gives: a skippable check recreates the silence the fact exists to break. Both cost a consumer two lines in its ComplianceSubscription partial, shown above.

The listener fact carries a probe rather than just a counter, because "it ran" and "it ran after the commit" are different claims and only the second is the contract β€” a listener called from inside the batch delegate fires again on every retry of a transaction that had already committed, which is the bug Fisher's local copy of this test records. The probe reads over a separate session and is safe, unlike the outbox's before-commit probe in ProjectionSideEffectCompliance, because the batch's transaction is already closed by the time it runs. It also does not wait on non-stale data and then assert: the progression row is written inside the batch's transaction, so non-stale is true strictly before a post-commit listener runs, and waiting on it is a race that fails perhaps one full-suite run in several. The listener is its own signal.

Rewind is pinned in both forms β€” from scratch, and to a sequence floor β€” and the second is also how the suite reaches a start position floor at all. A configuration-time floor (SubscribeFromSequence(n) and its siblings) cannot be asserted portably: the floor is a literal in a static configuration delegate while the sequences under test are assigned at run time, so a suite could only guess a constant and would end up asserting either "everything arrived" or nothing. RewindSubscriptionAsync takes the same floor, is on the shared IProjectionDaemon, and lets the floor be chosen after the events exist β€” which is what turns the exclusion into a claim.

Only the event-type filter fact is gated (SupportsSubscriptionEventFilters, default false), and for a reason worth stating so it does not read as timidity: the allow list has to survive a hop no shared code can make. Every product wraps a bare ISubscription in its own SubscriptionBase, the daemon reads filters from the wrapper, and Marten's SubscriptionWrapper copies none across β€” so a registrar has to replay ComplianceSubscription.IncludedEventTypes onto its own registration call, and until it does, the filter reaches nothing and the fact would fail for a wiring reason rather than a behavioral one.

The document contract (jasperfx#647)

A second, independent family covers the small document slice that JasperFx.Events now abstracts alongside the event store β€” JasperFx.Events.Documents:

Area Suite
Session opening and the transaction boundary DocumentSessionCompliance
Store and LoadAsync β€” Guid, string and strong-typed identities DocumentLoadAndStoreCompliance
Delete, its identity overloads, and DeleteWhere DocumentDeleteCompliance
Query<T>(), its minimum translatable operator set, and the async terminators DocumentQueryCompliance
The route from a session to its event store DocumentSessionEventsCompliance
The stream actions a session has queued but not committed PendingStreamActionsCompliance
Post-commit session listeners and the change set they receive DocumentCommitListenerCompliance

Enrollment is deliberately much cheaper than the event side. DocumentStorageComplianceFixture has three abstract members β€” build a store, hand back an IDocumentSessionFactory, wipe the data β€” and is not generic over the store's session pair, because everything the document suites do runs through the shared contracts. That asymmetry is the result being demonstrated, not an inconsistency: if a document suite ever needs a fixture member that reaches past the interfaces, the contract has a hole and the contract is what should change.

public class my_document_fixture : DocumentStorageComplianceFixture
{
    protected override Task BuildStoreAsync(DocumentComplianceConfig config) { /* ... */ }
    public override IDocumentSessionFactory Sessions => _store;
    public override Task CleanDocumentDataAsync() { /* ... */ }
}

public class document_query_compliance : DocumentQueryCompliance<my_document_fixture>;

DocumentSessionEventsCompliance and PendingStreamActionsCompliance are opt-in: alone among the document suites they need the store to be an event store as well, so a document-only implementer simply does not enroll them. Both members they cover β€” IDocumentReadOperations.Events / IDocumentSessionOperations.Events (jasperfx#669) and IDocumentSessionOperations.PendingStreams (jasperfx#673) β€” ship with throwing defaults, and both are reachable by a near-miss that the compiler does not catch: C# interface implementation is not return-type covariant, so a session already declaring a member of the same name with the product's own type binds to the default instead of implementing the contract. Only a test calling through a contract-typed session notices.

DocumentCommitListenerCompliance (jasperfx#679) is opt-in for a different reason: it needs only documents, so any store implementing the document contract can enroll, but it needs BuildStoreAsync to replay config.CommitListeners onto the store's own listener collection β€” StoreOptions.Listeners on all three products. That member exists because registration happens when the store is built, before any session exists, so a suite working only through the sessions the fixture hands out could not register a listener at all.

It is also the suite whose failure mode nothing else can reach. Neither IDocumentCommitListener nor IDocumentChangeSet ships a default implementation, so unlike the two members above, a store that declares them wrongly gets a compile error rather than a silent bind to a throwing default. What no compiler sees is the wiring: a store that declares both interfaces perfectly and never invokes the listener builds clean and passes every other suite here. Deleting the listener loop from this repo's own in-memory reference store fails 8 of the suite's 10 facts; the 2 that still pass are the two that assert the listener does not fire.

Two behaviors it deliberately does not assert, because the products disagree and the contract permits both: an empty unit of work (Fisher short-circuits and raises nothing; Marten matches but never stated it) and a session enlisted in a caller's ambient transaction (Fisher does not fire, since the enclosing transaction rather than SaveChangesAsync is what makes the data durable; Marten fires unconditionally). The second is unreachable from a suite in any case β€” enlistment is spelled on each product's own SessionOptions, which IDocumentSessionFactory does not expose β€” so it belongs in each store's own tests.

BuildStoreAsync must honor config.ValueTypes as well as config.DocumentTypes β€” every store spells that options.RegisterValueType(type). It is what lets DocumentLoadAndStoreCompliance hold the LoadAsync<T>(object) overload (jasperfx#665) to a definition; a fixture that ignores it fails the strong-typed identity tests rather than skipping them.

The same goes for config.StreamIdentity, which is nullable β€” leave the store on its own default when it is null, and set it when it is not. Only the event-capable document suites populate it, and they do so because they append by stream key. This is the one knob whose absence was a suite bug rather than a fixture's (jasperfx#672): DocumentSessionEventsCompliance needed string stream identity and had no way to say so, so three of its five facts failed on every store defaulting to Guid, with an error naming stream identity but nothing about the suite's requirement. A precondition a config cannot carry is a precondition each fixture has to guess.

That overload also shows what these suites are for. It ships with a default implementation, so a store takes the JasperFx bump without a compile break β€” the default forwards a boxed Guid or string and throws on anything else. Nothing in the compiler then tells the store it has only half the member. DocumentLoadAndStoreCompliance does: a store that inherits the default fails the strong-typed facts. Where the contract's defaults deliberately stop breaking builds, the suite is what is left holding stores to the behavior.

These suites are the one part of the library that is executed inside this repo as well as by its consumers: EventStoreTests enrolls an in-memory reference implementation, so the shared definition is known to be satisfiable before three products are held to it. That reference implementation is a test double, not a product β€” it exists to keep the suite honest.

Note that a consumer whose test project carries a global using Marten; (or the Polecat equivalent) will hit an ambiguity between that product's async LINQ terminators and JasperFx.Events.Documents.DocumentQueryableExtensions, which share names and receiver types. Scoping or removing the global using for the compliance compile resolves it.

What is deliberately out of scope

Storage layout and DDL, table partitioning, node distribution / HotCold, and high-water detection internals. If a behavior only makes sense in terms of one engine's storage, it belongs in that product's own test suite, not here.

General LINQ and query-provider behavior is out of scope permanently, not pending a contract. The stores' query languages diverge structurally enough that a shared suite would pin coincidence rather than contract. This has been the position since the library was designed; it is restated here because it had drifted into "blocked until a shared document store contract exists", which wrongly reads as deferred work. It is not deferred. A file like Polecat.Tests/Linq/additional_linq_operator_tests.cs is a product-owned test file, not a port awaiting absorption (marten#5155).

DocumentQueryCompliance is not a counter-example to that, and must not be allowed to grow into one. It pins a closed minimum translatable set β€” Where, Select, OrderBy / OrderByDescending, ThenBy / ThenByDescending, Take, Skip, Distinct β€” because a consumer holding only an IQueryable<T> has no way to discover whether a store translates those or silently does not. That set is closed by measurement (the operators CritterWatch.Services actually applies to a Query<T>() chain), not open by principle. Operators outside it stay product-owned however many stores happen to support them.

Session semantics are now in scope, via the document contract above. The rest of the document-db side β€” patching, bulk insert, LINQ joins / grouping / Include, soft-delete semantics, document metadata, session listeners, the stores' Advanced surfaces and schema management β€” stays out, and that exclusion is now a settled boundary rather than an open question: those are the surfaces jasperfx#647 deliberately declined to abstract.

New cross-store event sourcing behavior should land as a compliance suite first, and only then be enrolled by each product.

Changes from Previous Version

No structural changes between v2.64.0 and v2.65.0.

Adoption Assessment

Assess this package against its supported frameworks, license, maintenance signals and dependency graph before making it a project standard.

Compatibility metadata lists 2 target frameworks; confirm the exact target used by your application.

Related Guides & Workflows

Learn how to evaluate, integrate, or audit this package in your .NET project.