One issue, additive. Nothing in this release is a breaking change.
IDocumentCommitListener (#485, jasperfx#679)
The store-agnostic post-commit session hook, shipped in JasperFx 2.52.0 and now implemented by Polecat — along with the IDocumentChangeSet / IDocumentDeletion pair it hands the listener.
public class AuditListener : IDocumentCommitListener
{
public Task AfterCommitAsync(IDocumentSessionOperations session, IDocumentChangeSet commit, CancellationToken token)
{
// commit.Inserted / .Updated / .Deleted — snapshots, safe to retain
return Task.CompletedTask;
}
}
opts.CommitListeners.Add(new AuditListener()); // StoreOptions, store-wide
// or per session:
var session = store.LightweightSession(new SessionOptions { CommitListeners = { listener } });Polecat already declared the same method under store-local type names, so this closes a naming gap rather than a capability gap — an existing IDocumentSessionListener keeps working untouched.
The inbound direction is the point
Making Polecat's own listener interface satisfy the contract would only cover the outbound direction. The direction the contract exists for is inbound: a listener that implements only IDocumentCommitListener, knowing nothing about Polecat, has to be registrable.
StoreOptions.CommitListeners is therefore typed List<IDocumentCommitListener> — to the contract, not to Polecat's interface — so such a listener registers with no adapter at all. A separate collection rather than widening Listeners, because neither type can implement the other: IDocumentSessionListener also declares BeforeSaveChangesAsync (which the contract deliberately does not abstract), and every parameter of the two AfterCommitAsync signatures differs.
Registration is on StoreOptions / SessionOptions rather than a DI sweep. A sweep would only serve stores built by AddPolecat, and new DocumentStore(options) is a first-class construction path — the document compliance fixture itself uses it — so a container-only registration would leave the contract unreachable for exactly the callers most likely to be embedding Polecat.
IChangeSet : IDocumentChangeSet, IDeletion : IDocumentDeletion — an adapter, not a rename
C# interface implementation is not covariant in a member's type, so IEnumerable<object> Inserted does not satisfy IReadOnlyList<object> Inserted, and IEnumerable<IDeletion> Deleted does not satisfy IReadOnlyList<IDocumentDeletion> Deleted even though the two deletion interfaces declare the identical { DocumentType, Id } pair.
The three explicit implementations are default interface members on IChangeSet itself, matching the IQuerySession.Events / IDocumentSession.PendingStreams precedents — and here that placement also means adding a base interface to a public interface is not a breaking change for an outside implementor.
Change sets are now snapshots
ChangeSet materialises Inserted / Updated / Deleted once, in its constructor, in a single pass with a switch on Role(). These were lazily-evaluated LINQ chains re-run on every access. Two reasons, and the first is correctness:
IDocumentChangeSetpromises snapshots —IReadOnlyList, notIEnumerable— because a listener may retain the change set past the commit boundary. A lazy chain is only as stable as the list it reads, andPolecatProjectionBatchbuilds its change set over a plainList<T>it has been accumulating.- A listener reading all three properties re-ran the whole chain three times, on a path that has just done its SQL round trips.
The single pass is cheaper than the lazy version for any listener reading more than one property, and costs one traversal (and zero allocations per empty category) for a store with no listeners at all.
WorkTracker is also an IChangeSet and is what a pre-commit caller holds, so it gets the same view — but memoised rather than eager, because unlike ChangeSet it is mutable, long-lived, and Reset() and reused after every commit. The memo is keyed off the Operations snapshot instance, which is already invalidated on every mutation — so "a snapshot the memo was not built from" is exactly the rebuild signal, and no new invalidation has to be threaded through Add / AddStream / Reset / Eject*.
Invocation lives in the existing loop in DocumentSessionBase.SaveChangesAsync, after the store-native listeners and on the same commit snapshot, so a consumer holding both kinds of listener sees one consistent view. It is inside the try after await tx.CommitAsync and deliberately not in the finally — the contract requires the callback if and only if the commit succeeded.
Compliance
DocumentCommitListenerCompliance enrolled as wave 11 — 10 of 10 facts pass. The fixture replays config.CommitListeners onto StoreOptions.CommitListeners with no adapter, which is the point of taking IDocumentCommitListener directly.
Verified non-vacuous by a negative control: with only the invocation loop disabled and everything else in place, the build still reported 0 errors and 8 of the 10 facts failed. That is the failure mode this contract warns about — unlike jasperfx#669 there is no throwing default for a near-miss to bind to, but the wiring is invisible to the compiler at every point, and a store that declares both interfaces perfectly and never invokes a listener passes every other suite in the library. The two facts that still passed are the two negative ones (work that was never committed, and the cancelled-commit branch), which is exactly right.
Dependencies
JasperFx, JasperFx.Events, JasperFx.Events.ComplianceTests, JasperFx.Events.SourceGenerator and JasperFx.SourceGenerator all move 2.51.0 → 2.52.0.