Shared behavioral compliance test suites for Critter Stack event stores. Ships as C# source, not a compiled assembly.
Install
| Product | Versions |
|---|---|
| .NET |
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.
The builder has one optional second member, Add(ProjectionBase projection, int stageNumber)
(jasperfx#917), for a fact that needs a custom projection in a stage rather than a snapshot β today
StreamArchivingCompliance.creating_and_deleting_within_one_async_batch_hands_no_deletion_downstream,
whose stage 2 records the synthetic ProjectionDeleted<TDoc,TId> events stage 1 hands downstream. It
carries a throwing default, and that fact is gated on SupportsAddingProjectionsToComposites
(default false). Implementing it is one cast and one forward β all three products expose
Add(projection, stageNumber) on their composite β then flip the gate:
public void Add(ProjectionBase projection, int stageNumber)
=> _composite.Add((IProjectionSource<IDocumentOperations, IQuerySession>)projection, stageNumber);
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.
Variable exception types
A handful of facts assert that an operation fails a particular way. Where the failure category is
shared across every store but the exception type is not, the suite names a
ComplianceExceptionKind and the fixture resolves it:
await ShouldFailWithAsync(ComplianceExceptionKind.ExistingStreamIdCollision,
() => SaveChangesAsync(session));
EventStoreComplianceFixture.ExceptionTypeFor(kind) is virtual, not abstract, and defaults to
the matching exception lifted into JasperFx.Events (UnknownEventTypeException,
NonExistentStreamException, ExistingStreamIdCollisionException,
EventDeserializationFailureException, StreamLockedException,
DefaultTenantUsageDisabledException). A store that adopted those β Polecat and Fisher subclass
them β overrides nothing.
The seam exists because a store may legitimately own its exception hierarchy. Marten is the standing
example: its all_exceptions_should_derive_from_MartenException convention test requires every
Marten exception to derive from MartenException, and C# single inheritance forbids deriving from
both that base and the lifted type. So Marten keeps its own six in Marten.Exceptions and nominates
them from MartenComplianceFixture. That is a store design decision, not a compliance gap.
Two things this does not mean:
- It is not a weakening. An exception of the nominated type is still required β "something threw" does not pass, and neither does the wrong type.
- It does not extend to categories that are genuinely shared already.
JasperFx.ConcurrencyExceptionandEventStreamUnexpectedMaxEventIdExceptionlive in JasperFx, every store throws them directly, andAlwaysEnforceConsistencyCompliance,FetchForWritingCompliance,AggregateWriteCacheComplianceandStringStreamIdentityCompliancekeep asserting those types outright. Do not route them through this seam.
Resist "tidying" a ComplianceExceptionKind assertion back to a hard type. Doing so quietly
re-imposes a shared hierarchy on a store that has declined it, and the failure lands on the store
rather than here.
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 |
| Vector and hybrid search, and their filters | DocumentSearchCompliance |
| 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.
Tenancy past the read path (jasperfx#898)
ConjoinedEventTenancyCompliance began as twelve facts about reads β what a tenant can see and what
it must not β which is the half that is easy to think of. Five more were added because the shared
stream id, this suite's sharpest trick, had never been pointed at anything but a read.
No fact anywhere in the library registered a projection and then wrote the same stream id in two
tenants, so a store that projected into a single-tenanted document table β one row keyed on the
stream id alone β passed everything. What that store really does is let the second tenant's
projection overwrite the first tenant's snapshot: no error on append, no error on read, and both
tenants afterwards reading a document describing the other one's stream.
inline_and_async_snapshots_of_a_shared_stream_id_stay_isolated_per_tenant is the fact for it, and
it covers both lifecycles because they fail independently β the inline write happens inside a session
that already knows its tenant, while the async write happens on a daemon shard where the tenant has
to be read off the event, carried through slicing and applied to the document write. A store can have
the first and not the second, and the second is what a deployment relies on. The suite's standard
configuration now registers both snapshots, so every other fact in it exercises the inline path too.
async_rebuild_across_many_tenants_produces_one_document_per_tenant_stream is the daemon statement of
the same rule, and it is a separate fact because a rebuild is not the append path replayed: it tears
the read side down and repopulates it out of the transaction that wrote the events, so the tenant has
to be reconstructed per event, and the teardown carries its own hazard β a wipe scoped to the
projection but not to the tenant deletes every tenant's rows on the first shard to run. Two hundred
tenants share one stream id, which is what makes the count an assertion: a correct store ends with two
hundred documents carrying that id, a store keying on the id alone ends with one, whatever the tenant
count. The number is large on purpose β two tenants fit inside a single daemon batch, where a grouping
bug has nowhere to show itself β and every tenant is read back rather than a sample, because losing
some tenants to a batch boundary is the interesting failure and sampling is how it gets missed.
The archiving pair covers an intersection neither suite owned: StreamArchivingCompliance has no
tenant facts and this one had no archiving fact. Archiving is a bulk update over a stream's rows plus
a flag, which is exactly the shape whose WHERE clause loses its tenant scope β and the only tenant
who notices is the one who did not ask. The assertions reach past the flag to the events and to
appendability, because a store that flipped the flag everywhere and left the rows alone passes the
first two. The second arm runs the same claim under string stream identity, on its own store and
schema: every fact here was Guid-only before, and (tenant_id, key) is a different composite key from
(tenant_id, id) on every store.
with_the_default_tenant_disabled_every_read_route_is_reachable_through_a_tenant_scope is the first
and only assertion of ComplianceExceptionKind.DefaultTenantUsageDisabled, which had been declared
and never used β and declaring a failure category nothing asserts is how a store ends up throwing
something else, or nothing. The reachability half is what matters, not the refusal. A store can
refuse the default tenant loudly and correctly and still be broken, if a surface has no tenant-scoped
opener at all; that is jasperfx#885 exactly, where IReadOnlyEventStore was unreachable on a tenanted
store because the only thing producing one took no tenant and the refusal landed at open time, before
any scope could be applied. So the fact walks the routes first β a tenant session, the tenant-scoped
reader, EventQuery.TenantId, QueryStreamStates(tenantId) β and asserts the refusal last, so a
store that fails it has already proved the surface works. Gated on SupportsDisablingDefaultTenant
(default false), and the gate short-circuits configuration: the fact builds its own store, and on a
store that ignores ComplianceStoreConfig.DisableDefaultTenantUsage every assertion in it is vacuous.
projection_side_effect_messages_carry_the_event_tenant closes the last one.
ProjectionSideEffectCompliance publishes only under the default tenant, so the tenant argument on
IMessageSink.PublishAsync<T>(T, string) was never asserted β and the shared RecordingMessageOutbox
received it and threw it away, which is a fair picture of how easily it is lost. It is not cosmetic: a
published side effect is the one thing a projection emits that leaves the store's transactional
boundary, so the tenant on it decides which database the downstream handler writes to. A message
published under the wrong tenant, or under the default one, is delivered, handled and persisted
somewhere else, and nothing inside the event store reports a problem. Two tenants each mann a
watchtower and each message must carry its own id, because a store stamping every message with one
hard-coded tenant passes a single-tenant assertion. Gated on SupportsMessageOutbox, and behind its
own configuration delegate for the reason that suite gives β installing the outbox drives store
construction through a registrar member with a throwing default.
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 |
| What an explicit numeric revision means on the update path | NumericRevisionCompliance |
| Conjoined (per-tenant) document tenancy | DocumentConjoinedTenancyCompliance |
IDocumentStoreDiagnostics and its write-side sibling β the console surface |
DocumentStoreDiagnosticsCompliance |
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.
DocumentStoreDiagnosticsCompliance (jasperfx#870) holds IDocumentStoreDiagnostics and
IDocumentStoreDiagnosticsWriter β the by-type-name, raw-JSON surface a monitoring console browses and
edits through β to the semantics #870 defined after finding the three stores disagreeing: soft-deleted
rows excluded unless asked for, results filtered to the requested type in a hierarchy, no tenant meaning
the default tenant (never "all tenants", never a tenant named ""), text ids converted to the stored
identity type, metadata on every row, a Subject equal to the store's IDocumentStoreUsageSource, and a
stale expected version refused with the current document. Setup writes through the session contract and
assertions read through the diagnostics one, because what is under test is whether a console sees what
the application wrote.
It needs a new seam, and that is not a hole in the session contract: the diagnostics surface is a
separate contract with nothing on Sessions to reach it through. Override DocumentDiagnostics (and
DocumentDiagnosticsWriter for the write facts) and flip SupportsDocumentDiagnostics /
SupportsDocumentDiagnosticWrites. Replay the two new config members β
config.SoftDeletedDocuments (Schema.For<T>().SoftDeleted()) and config.SubClasses
(Schema.For<TRoot>().AddSubClass<TSub>()) β and flip SupportsSoftDeletedDocuments /
SupportsDocumentHierarchies. The tenancy facts reuse SupportsConjoinedDocuments.
SupportsDocumentDiagnosticCriteria is a fork rather than a skip. Left false, the suite asserts that a
Where or OrderBy is refused with DocumentCriteriaNotSupportedException β silently returning the
unfiltered page is the one wrong answer. Flip it once the store applies Dynamic LINQ criteria
(jasperfx#869) and the filtering facts run instead.
SupportsDocumentDiagnosticAllTenants (jasperfx#928) forks the same way. DocumentQueryOptions.AllTenants
is the explicit "every tenant" read, since a blank tenant means the default tenant. Left false on a store
with SupportsConjoinedDocuments, the suite asserts an all-tenants read is refused with
DocumentCriteriaNotSupportedException (Criterion == "AllTenants") rather than narrowed to the default
tenant. Flip it once the store reads every tenant; the facts then pin one row per tenant for a shared id,
each with its TenantId, and paging that never repeats a row across tenants. Combining AllTenants with
a named TenantId throws ArgumentException whatever the flag says: call
DocumentQueryOptions.AssertValidTenantScope() before reading.
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.
NumericRevisionCompliance (jasperfx#785 Β§4.2) is opt-in through SupportsNumericRevisions (default
false), and it is the one document suite that exists to record a ruling rather than to discover
a shared behavior. The three stores did not agree. Marten and Fisher hold that an explicit revision
must be strictly greater than the stored one β Marten's guard is
(? = 0 or {table}.{version} < ?) with the assignment CASE WHEN ? = 0 THEN {version} + 1 ELSE ? END,
and Fisher's NumericRevision is the same pair, with a comment saying it follows Marten on purpose.
Polecat made the doc-carried revision an equality expectation instead
(AND (? = 0 OR t.version = ?), assigning ELSE ? + 1), also on purpose, also documented locally,
neither store's comment referencing the other's. Matching test names over opposite contracts, which
is the failure mode this whole library exists to catch.
The maintainer ruled for strictly-greater, so this suite pins it and Polecat changes
(polecat#559). Nine facts: revision 0 is the auto sentinel and always wins, whatever is stored; a
new document lands at 1; an explicit revision greater than the stored one is accepted and lands at
exactly that value; an explicit revision equal to the stored one is refused, as is one below
it; and an explicit revision may jump non-contiguously (3 β 10). That last one is the capability
an equality rule cannot express, and it is much of why the ruling went this way β it is what lets a
caller adopt a revision decided somewhere else rather than one the store is counting.
The insert path follows the same rule, ruled separately once the update path settled: a brand-new
document carrying an explicit revision lands at exactly that revision rather than at 1. Marten's
insert value is CASE WHEN ? = 0 THEN 1 ELSE ? END and Fisher's is the same; Polecat hard-codes 1
and discards the caller's revision, which is now part of polecat#559 rather than an open question.
Two facts cover it, and the second is the one that matters: a store ignoring revisions entirely still
serializes Version as an ordinary property and hands the same value back on load, so reading the
revision off a loaded document passes vacuously. Following the insert with an auto store β which must
land at 8, not 2 β is what has teeth.
Two neighbouring insert cases are deliberately unpinned. An explicit revision of 0 on a new
document means auto and lands at 1, but that is already the a_new_document_lands_at_revision_one
fact and is not restated. A negative revision is left alone because it is undefined rather than
divergent: no store range-checks one, none tests it, Marten and Fisher would store it verbatim and
then refuse every later write below it, and Polecat's hard-coded 1 would swallow it. Three
accidents, not two contracts and a bug β the question there is which behavior to choose, and it
should be ruled before it is pinned.
The observable consequence is worth stating plainly because it is a sharp edge: loading a document at
revision 5 and storing it back still carrying 5 is a ConcurrencyException, not a read-modify-write.
Callers spell it doc.Version + 1. Refusal is asserted as the shared JasperFx.ConcurrencyException
rather than any store's own type, since that base is what a store-agnostic consumer catches.
No seam members, and none were needed: Store passes the document's own IRevisioned.Version as the
expected revision, so setting Version before storing names the revision. Store(doc, revision),
UpdateRevision and TryUpdateRevision stay product API and off the contract. SupportsNumericRevisions
is virtual, not abstract β the fixture's three abstract members are still three.
Conjoined document tenancy (jasperfx#898)
DocumentConjoinedTenancyCompliance is the document mirror of ConjoinedEventTenancyCompliance, and
until jasperfx#898 it did not exist: the event side had twelve tenancy facts enrolled by all three
stores while DocumentComplianceConfig, DocumentStorageComplianceFixture and every
Document*Compliance suite had no tenancy seam at all. DocumentSearchCompliance cites fisher#285
β a store ignoring conjoined tenancy in its search path β as a reason to exist, and then configures no
tenancy. That is the shape of the gap.
Two things carry the weight, both borrowed from the event suite because they are what separates an isolation assertion from a vacuous one. Every fact checks both directions, because a store that leaks still answers correctly for the tenant that owns the data. And every fact reuses one document id across two tenants, because under conjoined tenancy a document's identity is (tenant, id) and a store keying on id alone does not fail loudly β it folds the two writes into one row, so the second silently overwrites the first and both tenants then read the same document. Distinct ids per tenant, which is what every tenanted fact in this library used before, passes cleanly on exactly that store.
It cost one contract change, and the contract was the right place rather than the fixture.
IDocumentSessionFactory deliberately had no tenant-scoped opener β its own remarks said overloads
"can be added additively later" β and a suite cannot reach one any other way, so
LightweightSession(string tenantId) and QuerySession(string tenantId) were added with throwing
defaults. That follows this fixture's standing rule: a document suite needing to reach past the
interfaces means the contract has the hole.
β οΈ The one thing that will bite an implementer. C# interface implementation is not return-type
covariant, so a store implementing IDocumentSessionFactory<TOperations, TQuerySession> has to write
the explicit non-generic forwarder as well:
IDocumentSessionOperations IDocumentSessionFactory.LightweightSession(string tenantId)
=> LightweightSession(tenantId);
Without it the product-typed member satisfies the generic interface, the contract member stays bound
to the throwing default, and the suites fail on a store whose tenancy is entirely correct. Same
near-miss as IDocumentReadOperations.Events and IDocumentSessionOperations.PendingStreams;
DocumentSessionFactoryDefaultsTests pins the shape so it is a documented fact rather than a
surprise.
Beyond the overloads a fixture replays DocumentComplianceConfig.ConjoinedDocuments β a per-type
list, because that is what all three stores spell (Schema.For<T>().MultiTenanted()) and because the
suite needs to hold conjoined and single-tenanted documents in one store. Dropping the replay does
not make the suite skip; gate it with SupportsConjoinedDocuments instead, and note that the
failure mode of dropping it is worse than usual β the isolation facts fail rather than pass, since a
single-tenanted store folds both tenants into one row.
The two escape facts β AnyTenant and TenantIsOneOf β are separately gated on
SupportsCrossTenantQueries and need a pair of fixture seam members, because the spelling differs in
kind and not merely in name: Marten writes an element predicate inside the Where
(Query<T>().Where(x => x.AnyTenant()), recognized from the method's declaring type), Polecat and
Fisher write an operator on the queryable. Neither form survives being written in shared source, the
same reasoning as HasTagFilter. The two are asserted in one fact and in that order on purpose: a
store whose escape is really "drop the tenant filter" satisfies the AnyTenant half perfectly and
then hands TenantIsOneOf every tenant's rows as well, so the narrow case only has teeth measured
against the wide one in the same arrangement.
GuidOptimisticConcurrencyCompliance carries a second route to the same guard, added by
polecat#720: a document whose version member is named by the store's own metadata mapping
(Schema.For<T>().Metadata(m => m.Version.MapTo(x => x.Etag))) rather than by IVersioned. It is
opt-in through SupportsMappedConcurrencyMember (default false) and declared through
config.MapVersionTo<T>(memberName) β a member name rather than an expression, because each store
spells the mapping with its own expression type.
This is the decision DocumentComplianceConfig.NumericRevisionTypes' remarks left open. Those
recorded that jasperfx#819 Β§2 could not become a suite because the declared route "has no document
member⦠what a suite would additionally need is a way to say which member is the revision, which is a
decision rather than an omission." This is that seam.
Why it earns five more facts rather than a note. This one field has now been found broken four
times independently β fisher#245, marten#5372, polecat#592, polecat#720 β and two of those came
after a shared suite existed for the marker-interface half. The marker route and the mapped route
are separate code paths in every store that offers both, so a store can be perfectly green on the
five original facts with the mapped route wired to nothing. That is exactly what polecat#720 was, and
what marten#5384 (superseding marten#5372) is. Hence a separate gate: folding it into
SupportsOptimisticConcurrency would let the whole mapped route ride in on the marker's green.
The reference store implements it, so the facts run here rather than skipping everywhere β the jasperfx#903 lesson applied in advance rather than after the fact.
β οΈ The Guid concurrency fact is a cautionary tale worth reading before writing a tenanted
variant of any other suite's fact (jasperfx#903). As shipped in 2.75.0 it asserted a refusal no
correct store could produce: it advanced tenant A's row by storing a loaded instance, then re-stored
that same instance and expected a ConcurrencyException. But a committed Store writes the
landed version back onto the instance β mandated by
GuidOptimisticConcurrencyCompliance.a_successful_write_moves_the_instances_own_version_on, which
exists so a long-lived instance stays usable β so the re-store guards on the current version and is
admitted. The only way to pass was to stop writing the version back, which fails the suite this fact
is a tenanted special case of. Two facts in two suites, contradicting each other, and no store able
to be green on both. "Stale" has to mean a separately loaded instance, which is what it now uses.
The reason it reached three stores is the part worth generalising: the fact skipped in this
repository, because the reference store left SupportsOptimisticConcurrency false. A fact that skips
everywhere it could run is a fact nobody has checked. The reference store now implements the Guid
guard and enrolls GuidOptimisticConcurrencyCompliance alongside the tenancy suite β and it had to,
because a contradiction between two suites is only visible to something enrolled in both.
Four things jasperfx#898 proposed and this suite deliberately does not cover: bulk-insert
duplicate handling, patching, soft deletes, and a delete-all-tenant-data administration call. All
four are on the settled out-of-scope list below β they are the surfaces jasperfx#647 declined to
abstract β and a tenant axis is not a reason to reopen them. The same line holds inside the query
fact: its operators are DocumentQueryCompliance's closed minimum translatable set plus the async
terminators, not the wider set (GroupBy, Sum, LoadMany, compiled queries) the issue named.
Pinning tenant scoping over an operator is also pinning the operator.
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.
DocumentSearchCompliance (jasperfx#842/#843) is the other apparent counter-example, and the line
it holds is different again. It is not a relevance suite and must not become one. How good a
store's ranking is depends on its tokenizer, its index parameters and whatever embedding model the
caller brought, none of which are shared β so nothing here asserts that a better match outranks a
worse one on text. What it pins is the handful of things a store-agnostic caller cannot discover for
itself: that nearest comes first, that the score is a distance (smaller is closer) on every
metric while a hybrid score is the opposite, that the store's own implicit predicates apply as they
do to Query<T>(), that a filter narrows before the limit, and that a wrong-length query vector is
refused rather than answered.
Every one of those was divergent somewhere when the suite was written, which is the argument for it:
fisher#285 ignored conjoined tenancy and the hierarchy filter outright, and neither Marten.PgVector
search had a soft-delete predicate where Polecat and Fisher both did. The suite is gated on
SupportsVectorSearch / SupportsHybridSearch, both default false, because
IDocumentReadOperations.Search carries a throwing default β a store that has not implemented search
compiles and throws, which is exactly what a capability gate is for.
β οΈ The hierarchy facts are the cautionary half of that paragraph. The suite cited fisher#285's
missing hierarchy filter as a reason to exist and then shipped with no fact for it β and every store
enrolled turned out to be wrong: Polecat on all three of its search surfaces (polecat#723), Marten on
vector search (marten#5440), each found by reading rather than by a test, because every store's own
search tests use a flat document type. A search returns its rows deserialized as the T the caller
asked for, so an unfiltered scan of a shared hierarchy table does not merely include the siblings, it
materializes them as the sub-class that was named β which is why those facts assert the
sub-class's own member and not only the row count. Naming a gap in a remark is not covering it.
Note the two things a fixture must do beyond flipping the flag: replay
DocumentComplianceConfig.VectorIndexes and FullTextIndexes, and β for the hierarchy facts β
replay config.SubClasses and flip SupportsDocumentHierarchies, the same seam
DocumentStoreDiagnosticsCompliance uses. A vector search reads a declared index, and every
store spells that declaration on its own options object, so a fixture that ignores the declaration
fails every fact rather than skipping β the jasperfx#672 rule again.
The one thing the hierarchy facts deliberately stay silent on is the CLR type of each row a ROOT
search returns. Query<TRoot>() resolves each row through its discriminator to its concrete type;
no store's search path does, and whether it should is a separate decision with a different blast
radius (marten#5440's other half, polecat#723 point 3). Pinning it here would fail every store over a
question none of them has answered.
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.
A tenant axis does not reopen any of it, and that is worth stating plainly because it has already
been proposed once. jasperfx#898 asked for tenant-scoping facts over bulk insert, patching, soft
deletes and a delete-all-tenant-data call; all four were declined, and DocumentConjoinedTenancyCompliance
covers the tenant behaviour of the contract's own operations instead. The argument for adding them is
always locally reasonable β tenancy makes any operation more interesting, and an unscoped
DeleteWhere really is dangerous β but it proves too much: every excluded surface becomes interesting
under tenancy, so accepting it once is accepting the whole list. Pinning tenant scoping over an
operation is also pinning the operation, on every store, forever. Tenant offboarding is the one with a
genuine claim to be tenancy rather than a document-db feature; it stays out because the only way to
reach it is each store's own Advanced surface, and it is not uniformly implemented (Polecat lacks the
API, filed there).
The same line runs through DocumentConjoinedTenancyCompliance's own query fact: its operators are
DocumentQueryCompliance's closed minimum translatable set plus the async terminators. GroupBy,
Sum, LoadMany and compiled queries were named in the issue and left out, because a suite asserting
that GroupBy is tenant-scoped is a suite asserting that GroupBy translates.
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
Adoption Assessment
Assess this package against its supported frameworks, license, maintenance signals and dependency graph before making it a project standard.
Related Guides & Workflows
Learn how to evaluate, integrate, or audit this package in your .NET project.