#679: store-agnostic post-commit document session listener - #680
Merged
Conversation
Adds IDocumentCommitListener / IDocumentChangeSet / IDocumentDeletion to
JasperFx.Events.Documents, plus the compliance suite that pins them and the
DocumentComplianceConfig seam the suite needs to register a listener.
The contract departs from the issue text in three places, each driven by
reading all three stores rather than by preference:
- Deleted is IReadOnlyList<IDocumentDeletion>, not IReadOnlyList<object>.
Only Marten's change set holds a deleted document instance; Polecat and
Fisher carry {DocumentType, Id} descriptors, so the filed shape was
unimplementable on two of three stores. Marten's element type also lives in
Weasel, so exposing it would re-couple every consumer to a package the
document contracts exist to keep out.
- AfterCommitAsync returns Task, not ValueTask, matching all three products.
- The change set collections are documented as SNAPSHOTS. Marten's IChangeSet
IS the live unit of work and is reset right after the listener loop, which
is why Marten carries Clone(); requiring IReadOnlyList forces the copy at
construction and removes the need for a clone step in the shared contract.
The XML docs also record the firing semantics the products do NOT share --
Fisher does not fire for a session enlisted in a caller's transaction while
Marten fires unconditionally, neither fires for daemon projection batches
(that half is IDaemonChangeListener), and Marten ejects patched types before
the listener loop. The compliance suite deliberately asserts none of the
divergent cases.
No store implements this yet. The in-memory reference store in EventStoreTests
does, so the suite is executed in this repo before three products are held to
it: 136 passed. Deleting its listener loop fails 8 of the suite's 10 facts.
Closes #679. Refs #647, #669, #673.
This was referenced Aug 18, 2026
This was referenced Aug 23, 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.
Closes #679. Refs #647, #669, #673.
No store implements this yet. Marten, Polecat and Fisher are all unchanged by this PR — it adds the contract, the compliance suite that holds a store to it, and the config seam the suite needs. Adoption is three follow-up PRs, one per store.
Scope note: #678 is not in this PR
This branch was originally scoped to #678 and #679. #678 turns out to be a duplicate of #673, which shipped in JasperFx 2.51.0 — the current pin in this repo and in all three stores. It landed as
IReadOnlyList<StreamAction> PendingStreamsonIDocumentSessionOperations, all three stores already implement it as explicit interface implementations, andPendingStreamActionsCompliancealready pins it.#678's proposed
IPendingStreamis strictly weaker than the shippedStreamAction, which already carriesId,Key,EventsplusActionTypeandTenantId. Nothing for #678 is included here; it should be closed as resolved-by-#673.The contract
Three departures from the issue text, each forced by the stores
1.
DeletedisIReadOnlyList<IDocumentDeletion>, notIReadOnlyList<object>. The filed shape is unimplementable on two of the three stores. Only Marten's change set holds a deleted document instance; Polecat'sIDeletionand Fisher'sIDocumentDeletioncarry{ Type DocumentType, object? Id }and nothing more. Polecat and Fisher could only ever answerIReadOnlyList<object>empty.It is also the honest shape rather than merely the achievable one: delete-by-id and
DeleteWherenever loaded a document to report, so a collection of instances would silently omit exactly the deletions a consumer is least likely to expect to be missing.The descriptor is owned by JasperFx rather than reusing Marten's element type, which is
Weasel.Storage.IDeletion— exposing it would re-couple every consumer to a package the document contracts exist to keep out. Cost to the stores is nil: Polecat and Fisher already declare this exact pair, and Marten'sIDeletioninheritsType DocumentTypefromWeasel.Core.IStorageOperationand declaresobject Id. It is named for Fisher's spelling becauseIDeletionis already taken in any file importingWeasel.Storage, which is the situation inside Marten.2.
Task, notValueTask. All three products spellAfterCommitAsyncreturningTask. A per-commit hook is not a hot path, andValueTaskwould turn every store's forward into an allocation-wrapping adapter for nothing.3. The collections are documented SNAPSHOTS. Marten's
IChangeSetis the session's live unit of work and is reset immediately after the listener loop — which is why Marten carriesIChangeSet.Clone(). DeclaringIReadOnlyListforces each store to materialize when it builds the change set, which is what lets a listener stash the change set and read it later, and is why the shared contract needs no counterpart toClone(). Pinned bythe_change_set_survives_the_session_moving_on.Firing semantics are documented because they are NOT uniform
This is the #672 lesson applied to behavior rather than configuration — a precondition the contract cannot carry is one every consumer and every fixture has to guess:
SaveChangesAsync, is what makes the data durable). Marten fires unconditionally. The contract forces neither, because forcing Marten's would make the callback announce writes an outer rollback can still discard.IDaemonChangeListener, and the remarks say so explicitly — otherwise a consumer registers one listener and concludes the store is dropping projection writes.The compliance suite deliberately asserts none of the divergent cases, and says why in its own remarks.
Where the silent failure lives — and it moved
Unlike #669's
Eventsaccessor, neither new interface has a default implementation, so the non-covariance trap cannot bite: a store declaring: IDocumentChangeSetwhoseInsertedis anIEnumerable<object>gets CS0535 at build time, not a member silently binding 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 in the library. That is what
DocumentCommitListenerComplianceexists for, and it is why a green build is not evidence this contract is satisfied.Compliance suite (10 facts)
JasperFx.Events.ComplianceTests/Suites/DocumentCommitListenerCompliance.cs:a_listener_fires_after_a_successful_commit·every_registered_listener_is_invoked·each_commit_raises_its_own_callback·the_change_set_carries_the_written_document·a_document_written_twice_is_reported_by_both_commits·a_deleted_document_is_reported_by_type_and_identity·the_listener_does_not_fire_for_work_that_was_never_committed·the_listener_fires_if_and_only_if_the_commit_succeeded·the_committing_session_is_handed_to_the_listener·the_change_set_survives_the_session_moving_onThe failed-commit fact is written as a biconditional rather than as "a failed commit does not fire". There is no way through
IDocumentSessionFactoryto make a commit fail that every store is obliged to honor — a pre-cancelled token is the closest the contract comes, and a store that completes the commit anyway is not thereby wrong. Asserting "fires if and only if the commit succeeded" is non-vacuous on both branches: a store that fires on a rolled-back transaction fails it, and so does a store that swallows the cancellation and then fails to report the commit it did perform.New config seam
DocumentComplianceConfig.CommitListeners+AddCommitListener(...). Registration happens when the store is built, before any session exists, so without this the suite is not merely awkward to write — it is unwritable. A fixture replays it onto its ownStoreOptions.Listeners; ignoring it fails every fact rather than skipping them, which is correct, since a listener never registered and a store that never invokes one are the same observable failure.This is the first config member that is not a
Type, and it has to be: a listener is registered as an instance on every product, and the suite has to hold the same instance it registered to read back what the store handed it.Verification
dotnet build jasperfx.slnx— 0 errors.EventStoreTestsnow implements the contract and enrols the suite, so it is executed in this repo before three products are held to it — 136 passed, 0 failed, in 138ms with no database.🤖 Generated with Claude Code
https://claude.ai/code/session_011mpctjngqnraWVnDaqWtYf