Polecat 5.12.0
Ten issues, filed from the CritterWatch-side cross-store parity sweep, closed in one wave. Four were live defects that silently corrupted or misreported state; the rest are hardening, a dependency wave, and the document abstractions Wolverine needs.
Defects fixed
Progression rows were deleted by an unescaped prefix LIKE (#436). Three paths — rewind, delete-progress and rebuild teardown — ran DELETE ... WHERE name LIKE @name with @name bound to name + "%". That is wrong twice over, and each half silently destroys a different projection's progression state. _, % and [ are all legal in a projection name and all three are T-SQL LIKE metacharacters, so day_summary matched dayXsummary, and a bracketed name did not even match itself — meaning that projection's own teardown left its own rows behind. Separately, a plain prefix sweep on day_summary also took day_summary_v2's rows, with no wildcard involved at all. Every root is now matched with exact equality, and the only pattern left is anchored on the : of the shard grammar with the wildcards escaped.
A renamed natural key kept resolving forever (#435). NaturalKeyProjection only ever upserted, so an event that changed an aggregate's [NaturalKey] left the row carrying the previous value behind, still pointing at the same stream. Three things went wrong at once: the superseded alias kept resolving, the table accumulated one dead row per rename, and — because natural_key_value is the primary key — the retired value permanently squatted on its slot so no other stream could ever claim it. A stream-scoped retire is now queued ahead of each upsert.
Composite members registered through a wrapper survived a rebuild (#439). A member registered as a raw IProjection is wrapped in CompositeIProjectionSource, which carried an empty AsyncOptions and published nothing — so it contributed no teardown at all, while its progression row was deleted. The rebuild then restarted from sequence zero and replayed onto a table still holding the previous run's rows: a silent double-count. The wrapper now adopts the wrapped projection's options and published types, a new Add(IProjection, Action<AsyncOptions>) overload lets a raw projection declare its teardown, and the composite's own options are honoured.
A malformed continuation cursor was a 500 (#438). The cursor is client input, so an unversioned, undecodable or wrong-arity one is a bad request — but the ArgumentException from CursorPagination.Decode escaped StreamPagedByCursor as a server fault. Now a 400, advertised in the endpoint metadata.
The high-water health check stopped lying (#434)
AddPolecatHighWaterHealthCheck treated the ExtendedProgression heartbeat column as its primary staleness signal, falling back to the sequence-gap heuristic only when no heartbeat was present. Nothing ever wrote that column for the HighWaterMark row — ExtendedProgressionWriter.OnNext drops those states outright, and Polecat's own high-water persist writes only last_seq_id and last_updated. So the primary branch was unreachable in every real deployment and the check silently degraded, while its own tests passed against a state no daemon ever produces because they seeded the column with raw SQL.
Verified with a running daemon this time, not seeded SQL. The gap heuristic now stands alone and the docs say so, pointing at IProjectionDaemon.HighWaterLastPolledAt / IsHighWaterStale for a liveness signal that does not depend on the mark advancing. The row read also stopped going through AllProjectionProgress, which pulled every projection × tenant row on every probe to keep one of them.
Async daemon
The extended-progression write is batched, without a lock convoy (#437). JasperFx's ExtendedProgressionWriter hands a whole flush over at once, but the default interface member looped the single-state overload — so a flush of N shard states rented N connections. The batch now lands on one connection as N single-row statements, each its own implicit transaction, in shard-name order. Deliberately not one multi-row statement: that shape built a lock convoy in Marten (marten#5167), where one slow projection batch on one row stalled every other shard's telemetry on the database. An unchanged replay is now a zero-row UPDATE rather than a fresh row version.
Tenant-filtered progression reads (#441). IEventDatabase.AllProjectionProgress(tenantId) has existed since #407 with a default that throws for a non-null tenant, and Polecat had never overridden it — so per-tenant progression views and per-tenant rebuild scoping simply could not scope to a tenant on the SQL Server flavour. Rows are matched structurally, on the parsed ShardName, never by testing whether a name ends with the tenant id: a tenant id may legally contain a : or end with another tenant's id as a suffix, and a string test gets both wrong in a way that silently returns another tenant's progress.
Document abstractions for Wolverine and CritterWatch (#443)
JasperFx 2.47.0 added JasperFx.Events.Documents — the store-agnostic document surface a consumer needs alongside the event store — so that the aggregate handler workflow can be implemented once in Wolverine core instead of once per store.
Polecat's own session types are those contracts. IQuerySession is IDocumentReadOperations, IDocumentOperations is IDocumentWriteOperations, IDocumentSession is IDocumentSessionOperations, IDocumentStore is IDocumentSessionFactory<,>, and PolecatLinqQueryProvider is IDocumentQueryExecutor. No adapter, nothing to wrap. Polecat is enrolled in all four new document compliance suites.
One API change to be aware of: six session members widened their generic constraint from where T : class to where T : notnull — LoadAsync by Guid/string, Query<T>, Delete<T> by Guid/string, and DeleteWhere<T>. A constraint relaxation is source-compatible for callers.
Hardening and dependencies
Streaming/ETag regression matrix (#438). Polecat's streaming result types were ported at marten#5015-parity time, before Marten hardened that surface across five follow-up fixes. The matrix is now ported and pinned: a revisioned document emits an ETag (Polecat's version column is bigint for every document, so Marten's Guid-only gate never existed here), a 304 writes no body, a Select() projection keeps its ETag source column, and a tracking session streams the document body rather than its id.
Batch reader alignment audit (#442). Preventive, in the marten#5210 class. Polecat's reader loops do not branch on the NoDataReturnedCall marker, so Marten's exact mechanism cannot occur — but two invariants hold the batches together and are now measured rather than assumed: an operation in the document batch contributes zero result sets, and the two operations that actually read never share a batch with ones that do not.
JasperFx 2.46.0 / 2.47.0 and Weasel 9.24.0 (#440, #443). jasperfx#644 bounds the per-tenant high-water path at thousands of tenants — a 512-database, 2,173-tenant deployment OOM'd its host, and Polecat drives that coordinator through the shared daemon. weasel#415 judges the SQL Server CREATE DATABASE by its postcondition, so the loser of a concurrent tenant-database provisioning race no longer surfaces its race failure when the database it wanted now exists.
Test changes worth calling out
Four existing tests changed rather than being added to, because they pinned the defects being fixed. natural_key_is_mutable_fetch_after_change asserted "fetch by old key still works"; two health-check tests asserted the heartbeat branch worked, and passed only because they seeded the column by hand. All now assert the corrected behaviour. Nothing was deleted.