gh-669: an Events accessor on the document session contracts, and promote IEventBinarySerializer - #671
Merged
Merged
Conversation
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.
This was referenced Aug 17, 2026
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>
This was referenced Aug 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
a4fbbee— closes #669: anEventsaccessor on the document session contractsA consumer that opens its own session through
IDocumentSessionFactoryhad no store-agnostic route to the event store. Every product declaresEventson 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 —
IEventStoreOperationsfor appends,IQueryEventStorefor 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:IDocumentReadOperationsIQueryEventStore EventsIDocumentSessionOperationsnew IEventStoreOperations EventsDeliberately not on
IDocumentWriteOperations— that is the tier a projection'sRaiseSideEffectsreceives, 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
IEventStoreOperationsdirectly 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.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 anEventsproperty 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:
IQuerySession.Eventsis already exactlyIQueryEventStore— satisfied as-isIDocumentOperations.EventsisMarten.Events.IEventStoreOperations— needs the one-line explicit implIQuerySession.Eventsalready exactlyIQueryEventStore— satisfied as-isPolecat.Events.IEventOperations— needs the one-line explicit implIQuerySessionhas noEventsat allEventOperations(a class) — needs the one-line explicit implStore-side PRs follow.
DocumentSessionEventsComplianceis opt-in rather than folded intoDocumentSessionCompliance, because it is the one document suite that requires the store to be an event store too. The in-memory reference store inEventStoreTestsis 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— promoteIEventBinarySerializerand[BinaryEvent]toJasperFx.EventsPrerequisite 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.csandCompressedJsonEventSerializer.Marten.csare 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.IEventBinarySerializerderiving from this one, so existing implementations continue to satisfy both it and any widened API — additive for every current consumer.The compliance suite
BinaryEventSerializationComplianceis the definition Polecat and Fisher implement against. Opt-in: the two newIComplianceStoreRegistrarmembers 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
jasperfx.slnxbuild: 0 errorsEventStoreTests: 121/121, net9.0