Skip to content

#679: store-agnostic post-commit document session listener - #680

Merged
jeremydmiller merged 1 commit into
mainfrom
feat/679-document-commit-listener
Aug 18, 2026
Merged

#679: store-agnostic post-commit document session listener#680
jeremydmiller merged 1 commit into
mainfrom
feat/679-document-commit-listener

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

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> PendingStreams on IDocumentSessionOperations, all three stores already implement it as explicit interface implementations, and PendingStreamActionsCompliance already pins it.

#678's proposed IPendingStream is strictly weaker than the shipped StreamAction, which already carries Id, Key, Events plus ActionType and TenantId. Nothing for #678 is included here; it should be closed as resolved-by-#673.

The contract

public interface IDocumentCommitListener
{
    Task AfterCommitAsync(
        IDocumentSessionOperations session,
        IDocumentChangeSet commit,
        CancellationToken token);
}

public interface IDocumentChangeSet
{
    IReadOnlyList<object> Inserted { get; }
    IReadOnlyList<object> Updated { get; }
    IReadOnlyList<IDocumentDeletion> Deleted { get; }
}

public interface IDocumentDeletion
{
    Type DocumentType { get; }
    object? Id { get; }
}

Three departures from the issue text, each forced by the stores

1. Deleted is IReadOnlyList<IDocumentDeletion>, not IReadOnlyList<object>. The filed shape is unimplementable on two of the three stores. Only Marten's change set holds a deleted document instance; Polecat's IDeletion and Fisher's IDocumentDeletion carry { Type DocumentType, object? Id } and nothing more. Polecat and Fisher could only ever answer IReadOnlyList<object> empty.

It is also the honest shape rather than merely the achievable one: delete-by-id and DeleteWhere never 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's IDeletion inherits Type DocumentType from Weasel.Core.IStorageOperation and declares object Id. It is named for Fisher's spelling because IDeletion is already taken in any file importing Weasel.Storage, which is the situation inside Marten.

2. Task, not ValueTask. All three products spell AfterCommitAsync returning Task. A per-commit hook is not a hot path, and ValueTask would turn every store's forward into an allocation-wrapping adapter for nothing.

3. The collections are documented SNAPSHOTS. Marten's IChangeSet is the session's live unit of work and is reset immediately after the listener loop — which is why Marten carries IChangeSet.Clone(). Declaring IReadOnlyList forces 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 to Clone(). Pinned by the_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:

  • Enlisted transactions. Fisher does not fire for a session enlisted in a caller's ambient transaction (the enclosing transaction, not 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.
  • Empty unit of work. Fisher short-circuits; Marten matches but never stated it. Permitted either way.
  • Daemon projection batches. Neither half fires. JasperFx already owns that as IDaemonChangeListener, and the remarks say so explicitly — otherwise a consumer registers one listener and concludes the store is dropping projection writes.
  • Marten ejects patched types before the listener loop, so patched documents are already absent from the change set a listener sees.

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 Events accessor, neither new interface has a default implementation, so the non-covariance trap cannot bite: a store declaring : IDocumentChangeSet whose Inserted is an IEnumerable<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 DocumentCommitListenerCompliance exists 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_on

The failed-commit fact is written as a biconditional rather than as "a failed commit does not fire". There is no way through IDocumentSessionFactory to 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 own StoreOptions.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.slnx0 errors.
  • The in-memory reference store in EventStoreTests now 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.
  • Mutation-checked: deleting the listener loop from the reference store fails 8 of the 10 facts. The 2 that still pass are exactly the two asserting the listener does not fire.

⚠️ No store-backed compliance suite was run. Nothing here touched Marten, Polecat or Fisher, and the Docker/Postgres/SQL Server-backed suites were deliberately not run locally. The claims about the three products' shapes and firing semantics come from reading their sources at the current pins, not from executing them — the per-store adoption PRs are where they get proven.

🤖 Generated with Claude Code

https://claude.ai/code/session_011mpctjngqnraWVnDaqWtYf

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.
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 store-agnostic post-commit session listener — one line of logic, written three times because IChangeSet/IDocumentSessionListener are per-store

1 participant