Polecat 5.15.0
Four fixes, all Marten-parity items, and all four surfaced the same way: by running one shared test suite against both Marten and Polecat to prove that store-facing consumer code is portable. That exercise is turning out to be a good bug detector — it finds the differences that only show up when the same source has to satisfy both stores.
Two of these change behaviour. Read #462 and #463 before upgrading.
FetchLatest<T> no longer returns a phantom aggregate (#463)
FetchLatest<T>(key) on a stream that exists but holds no event T handles returned a non-null, default-constructed aggregate. Marten returns null.
That matters because FetchLatest<T>(key) is null is the idiomatic "does this aggregate exist?" probe — code branching between StartStream and Append leans on it. Under the old behaviour the probe was satisfied by any stream key that had events at all, so the answer depended on whether some other aggregate happened to share the key space. Worse, the phantom read as real state: an AlertRecord whose IsActive defaults to true came back as an active alert where the correct answer was "no alert".
It was a missing fetch plan, not a missing filter
Polecat live-aggregated the stream on every FetchLatest, whatever T's lifecycle. Marten's planner does not: it routes an Inline aggregate to FetchInlinedPlan, which simply loads the projected document — and so finds nothing, and returns null, for a stream the projection does not own.
Polecat now does the same, which also makes the read path agree with what the write path already believed. The inline projection screens out streams it does not own, which is exactly why no document was ever written for them. The two halves were disagreeing; now they don't.
Which aggregates were actually affected
Only aggregates whose handler is a catch-all Evolve(IEvent):
| aggregate shape | FetchLatest on a foreign stream, before |
|---|---|
Create + Apply methods |
null — nothing built an instance |
Apply only |
null |
catch-all Evolve(IEvent) |
default-constructed instance |
A catch-all accepts every event type at the method level, so nothing in the aggregation path filtered by applicability: the aggregator default-constructed an instance, the switch inside matched nothing, and the default came back dressed as state. This is also why an event-type applicability filter would have been the wrong fix — a catch-all genuinely declares that it handles everything, so such a filter has to let it through.
What changes for you
Inlineaggregates:FetchLatest<T>reads the projected document. Within a session, beforeSaveChangesAsynchas run the inline projection, you therefore get committed state —ProjectLatestremains the call that folds in pending events.LiveandAsyncaggregates: unchanged, they still aggregate the stream.- Natural keys: unchanged. A natural key resolves to a stream key (string) for an aggregate whose document id is a
Guid, and that key cannot address the document at all, so those keep aggregating the stream. The document path is gated on the aggregate'sInnerIdTypematching the key type — which means a strongly-typed id still matches on the value it wraps.
A disposed session now throws (#462)
Polecat's session had no disposed flag. After await session.DisposeAsync() the same instance still accepted Store / Events.Append and still committed successfully on SaveChangesAsync, because the connection lifetime simply re-established itself lazily.
Use-after-dispose is normally a loud, immediate bug. Here it was silent, and the silence favoured the worst case: a session captured past the scope that owned it goes on writing to the database, with nothing owning the transaction boundary any more.
Mirroring Marten/Internal/Sessions/QuerySession.Disposal.cs, there is now a _disposed field set by an idempotent DisposeAsync, plus assertNotDisposed() at the session entry points:
| Where | Members |
|---|---|
| writes | Store, StoreObjects, Insert, Update, every Delete/HardDelete shape, DeleteWhere/HardDeleteWhere/UndoDeleteWhere, QueueSqlCommand, SaveChangesAsync, ForTenant |
| events | the accessor every Append / StartStream / AppendOptimistic passes through |
| reads | Load*, LoadMany*, CheckExists*, LoadJson*, Query<T>(), CreateBatchQuery() |
| backstop | the four Execute* methods, so no DB round-trip from any path — LINQ, batched queries, event reads — can outlive disposal |
This can surface latent bugs in consuming code. Anything that was quietly using a session past its scope was previously committing; it now throws ObjectDisposedException. That is the point, but it is worth a look before upgrading.
ConfigurePolecat((sp, opts) => ...) for the main store (#456)
The (IServiceProvider, StoreOptions) shape existed only on the generic ConfigurePolecat<T> for typed/ancillary stores. Code that post-configures the main store and needs the built provider had no extension method to call — exactly the situation a consumer lands in when it collapses onto a single store, while its configuration stays provider-dependent.
builder.Services.ConfigurePolecat((services, options) =>
{
var settings = services.GetRequiredService<IOptions<RetentionSettings>>().Value;
options.Schema.For<MetricsSample>().PartitionOn(x => x.Timestamp)
.ByRollingRange(RollingPeriod.Day, ahead: 2, behind: settings.DaysRetained);
});Marten has the equivalent ConfigureMarten((sp, opts) => ...). The workaround — a small IConfigurePolecat class, whose Configure(IServiceProvider, StoreOptions) already carried the provider — was always on the supported path; only the extension method was missing.
📖 Configuration → Host Builder
44 CS0108 warnings gone (#455)
Wiring Polecat's session interfaces onto the JasperFx.Events.Documents contracts in 5.12.0 left the pre-existing declarations in place, so each one hid the member it also inherits — 22 warnings per TFM across IQuerySession, IDocumentOperations, IDocumentSession, and IDocumentStore.
Behaviour was never affected: a single implementing method implicitly satisfies every interface slot with a matching signature, which is why the document compliance tests — which call exclusively through the shared contracts — were green throughout. The eleven members are now marked new, which states the intent and keeps Polecat's own doc comments, which are better worded for a Polecat audience than the generic contract ones.
Verification
Every PR merged with green CI on both the default and Azure SQL Edge matrices, and each was additionally checked against a full local suite run. The #463 fix was pinned by reproducing the reported phantom first — PHANTOM(IsActive=True) with zero documents before, null after — and its headline test fails without the fix.
The full-suite run is also what caught the natural-key id-type mismatch in the first #463 attempt, as an InvalidCastException in natural_key_string_identity_tests.
Full suite at 2154.