Skip to content

gh-669: an Events accessor on the document session contracts, and promote IEventBinarySerializer - #671

Merged
jeremydmiller merged 2 commits into
mainfrom
feature/669-session-events-accessor
Aug 17, 2026
Merged

gh-669: an Events accessor on the document session contracts, and promote IEventBinarySerializer#671
jeremydmiller merged 2 commits into
mainfrom
feature/669-session-events-accessor

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Two independent changes, one per commit — happy to split into separate PRs if you'd rather review them apart. Both are additive: no existing store needs a code change to keep compiling.

Driven by CritterWatch's store decomposition (JasperFx/CritterWatch#999), and both are on the critical path for its 1.0.


1. a4fbbeecloses #669: an Events accessor on the document session contracts

A consumer that opens its own session through IDocumentSessionFactory had no store-agnostic route to the event store. Every product declares Events on its own session type, never on the shared contract, so the moment a consumer takes the store-agnostic route the event store becomes unreachable from the session it just opened.

The far side already existed — IEventStoreOperations for appends, IQueryEventStore for reads — so this is the route to it and nothing more.

Split across the two tiers the document contracts already draw, so a QuerySession() cannot append:

tier member
IDocumentReadOperations IQueryEventStore Events
IDocumentSessionOperations new IEventStoreOperations Events

Deliberately not on IDocumentWriteOperations — that is the tier a projection's RaiseSideEffects receives, and a projection may write documents but must not append events or commit. The daemon owns both.

Why it doesn't show up in the handler case

It doesn't bite chain parameters at all: wolverine#3956 (6.28.2) lets a handler take IEventStoreOperations directly and Wolverine fills it from the chain's own session. It bites the two shapes where the consumer, not Wolverine, decides when a session exists — a background/timer publisher that opens a session and appends, and a deliberate second read session opened alongside a chain's writing session.

⚠️ The trap the default creates, and why the compliance suite is the real deliverable

Both members carry the throwing default the contract established for LoadAsync<T>(object), so no store breaks on upgrade. But C# interface implementation is not return-type covariant, so a session that already declares an Events property of the product's own event-store type does not satisfy the member — it silently binds to the throwing default instead, with no compile error anywhere.

Measured against the three stores as they stand today:

store read tier write tier
Marten IQuerySession.Events is already exactly IQueryEventStore — satisfied as-is IDocumentOperations.Events is Marten.Events.IEventStoreOperations — needs the one-line explicit impl
Polecat IQuerySession.Events already exactly IQueryEventStore — satisfied as-is Polecat.Events.IEventOperations — needs the one-line explicit impl
Fisher IQuerySession has no Events at all EventOperations (a class) — needs the one-line explicit impl

Store-side PRs follow.

DocumentSessionEventsCompliance is opt-in rather than folded into DocumentSessionCompliance, because it is the one document suite that requires the store to be an event store too. The in-memory reference store in EventStoreTests is a document-only implementer and correctly stays on the throwing default — it still passes 121/121 untouched, which is the evidence that the default does its job.

The suite asserts the route, not the event store behind it: reachability through a contract-typed local (binding the local to the product's own session type is exactly the mistake that makes the non-covariance trap invisible), that appends ride the session's unit of work rather than writing straight through, that documents and events written through one session commit together, and that events read back.


2. d45ec16 — promote IEventBinarySerializer and [BinaryEvent] to JasperFx.Events

Prerequisite for JasperFx/polecat#475 and JasperFx/fisher#93.

Binary event serialization originated in Marten (JasperFx/marten#4515) and is store-namespaced, so a consumer compiling one body of source against several stores needs a separate, identical copy of every serializer per flavour. CritterWatch has exactly that today — MessagePackEventSerializer.Marten.cs and CompressedJsonEventSerializer.Marten.cs are excluded from its Polecat and Fisher flavours — so its measured 904 → 19 KB/min win is Marten-only. Promoting the two-method interface lets one serializer serve every store.

The attribute comes along for the same reason: attributes are applied to event types, and event types are precisely what such a consumer shares, so a store-namespaced attribute is unusable in the code that most wants it. Promoting the interface alone would have left the feature reachable store-agnostically through registration but not through declaration.

Marten keeps Marten.Events.IEventBinarySerializer deriving from this one, so existing implementations continue to satisfy both it and any widened API — additive for every current consumer.

The compliance suite

BinaryEventSerializationCompliance is the definition Polecat and Fisher implement against. Opt-in: the two new IComplianceStoreRegistrar members carry throwing defaults, so a store without binary storage does not enroll, never reaches them, and still compiles against this package.

Its serializer gzips the UTF8 JSON deliberately. A serializer emitting plain JSON would round-trip identically whether the store honored it or quietly fell back to its own JSON path, so every fact would pass against a store that implemented nothing. It also asserts the serializer was actually called, and that JSON and binary events coexist in one stream — the property that makes turning a single event type binary an in-place change with no data migration.

Which column carries the bytes and how a row marks itself binary are left unasserted: both are genuinely per-store, and pinning either would encode Marten's schema as the contract rather than the behavior.


Gates

  • Full jasperfx.slnx build: 0 errors
  • EventStoreTests: 121/121, net9.0

A consumer that opens its own session through IDocumentSessionFactory had no
store-agnostic route to the event store: every product declares Events on its
own session type, not on the shared contract. The far side already existed --
IEventStoreOperations for appends, IQueryEventStore for reads -- so this is the
route to it and nothing more.

Split across the two tiers the document contracts already draw, so a
QuerySession() cannot append: IQueryEventStore on IDocumentReadOperations,
narrowed to IEventStoreOperations on IDocumentSessionOperations. Deliberately
NOT on IDocumentWriteOperations, which is the tier a projection's
RaiseSideEffects receives -- a projection may write documents but must not
append events or commit.

Both members carry the throwing default the contract established for
LoadAsync<T>(object), so no store breaks on upgrade. The trap that default
creates is the reason for the new compliance suite: C# interface implementation
is not return-type covariant, so a session that already declares an Events
property of the product's own event-store type does not satisfy the member --
it silently binds to the default instead, with no compile error anywhere.

DocumentSessionEventsCompliance is opt-in rather than folded into
DocumentSessionCompliance because it is the one document suite that requires
the store to be an event store too; the in-memory reference store in
EventStoreTests is a document-only implementer and correctly stays on the
default. It asserts the route, not the event store behind it -- reachability
through a contract-typed local, that appends ride the session's unit of work
and commit with the document writes, and that events read back.
Binary event serialization originated in Marten (marten#4515) and is
store-namespaced, so a consumer compiling one body of source against several
stores needs a separate, identical copy of every serializer per flavour.
CritterWatch has exactly that today -- MessagePackEventSerializer.Marten.cs and
CompressedJsonEventSerializer.Marten.cs are excluded from its Polecat and Fisher
flavours -- so its measured 904 -> 19 KB/min win is Marten-only. Promoting the
two-method interface here lets one serializer serve every store; the storage
half follows in polecat#475 and fisher#93.

The attribute comes along for the same reason and at the same time. Attributes
are applied to event types, and event types are precisely what such a consumer
shares, so a store-namespaced attribute is unusable in the code that most wants
it. Promoting the interface alone would have left the feature reachable
store-agnostically through registration but not through declaration.

Marten keeps Marten.Events.IEventBinarySerializer deriving from this one, so
existing implementations continue to satisfy both it and any widened API -- the
promotion is additive for every current consumer.

BinaryEventSerializationCompliance is the definition Polecat and Fisher
implement against, and it is opt-in: the two new registrar members carry
throwing defaults, so a store without binary storage does not enroll, never
reaches them, and still compiles against this package.

Its serializer gzips the UTF8 JSON deliberately. A serializer emitting plain
JSON would round-trip identically whether the store honored it or silently fell
back to its own JSON path, so every fact would pass against a store that
implemented nothing. The suite also asserts the serializer was actually called,
and that JSON and binary events coexist in one stream -- the property that makes
turning a single event type binary an in-place change with no data migration.
Which column carries the bytes, and how a row marks itself binary, are left
unasserted: both are genuinely per-store, and pinning either would encode
Marten's schema as the contract rather than the behavior.
@jeremydmiller
jeremydmiller merged commit 8d0da7c into main Aug 17, 2026
1 check passed
jeremydmiller added a commit that referenced this pull request Aug 17, 2026
…t needs (#677)

DocumentSessionEventsCompliance appends by stream *key* throughout, but
DocumentComplianceConfig carried no StreamIdentity, so the suite never told the store
what it needed. Three of its five facts failed on any store defaulting to Guid stream
identity -- which is every current store -- with an error naming stream identity and
nothing about the suite's requirement.

That is a bug in the suite by definition. The whole point of a shared compliance
library is that implementing the contract is sufficient to pass it; here a correct
store failed until it guessed an undocumented precondition, and Fisher only got past
it by inferring string identity fixture-side from the presence of event types.

Adds StreamIdentity to DocumentComplianceConfig, nullable and defaulted to null so a
document-only suite leaves the store on its own default and needs no fixture change,
and has DocumentSessionEventsCompliance declare AsString. Mirrors
ComplianceStoreConfig.StreamIdentity exactly, including why it is a plain property:
the value is the shared enum, but the options object it hangs off is the store's own
event graph, and the fixture already knows which.

The asymmetry that hid this is worth recording, so the class docs now do. The event
side's config has plenty of knobs and its suites choose identity deliberately;
DocumentComplianceConfig was written thin on purpose for #647, and gaining
EventTypes in #671 was the first time it had to describe anything about the event
store at all. Thin is still right -- but a precondition the config cannot carry is a
precondition each fixture has to guess.

Also unseals BinaryEventAttribute. Marten shipped its own before this one was
promoted, cannot delete it without breaking users, and could not derive from a sealed
one (CS0509) -- so EventGraph.ResolveBinarySerializerFor now checks two attribute
types indefinitely. Unsealing is non-breaking and lets such a store subclass this one
instead, collapsing that back to a single lookup, because attribute lookup matches by
assignability. Polecat and Fisher are unaffected; the docs say plainly that a store
without a pre-existing attribute should use this one directly rather than
subclassing, since a store-namespaced subclass would reintroduce exactly what
promoting the attribute removed.

Both fixes get in-repo tests, because neither needs a real store and nothing caught
either the first time: that the suites declare what they declare, and that a derived
attribute is found by a lookup for the promoted one.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No Events accessor on the JasperFx.Events.Documents session contracts — a consumer-opened session cannot reach the event store

1 participant