
JasperFx's focus right now is on trying to deliver CritterWatch 1.0, and that effort is leading to quite a few improvements upstream throughout the Critter Stack. Plus our normal highly engaged community dropping in pull requests or writing up detailed reproductions for issues.
Our last week in review covered twenty-two releases and promised that the Critter Stack gets curated constantly rather than in big bang drops. Since then we've shipped sixty-three releases across Marten, Wolverine, Polecat, Fisher, Weasel, and the shared JasperFx libraries, plus three CritterWatch release candidates.
That's too much to list, so here's what actually changes what you can build — plus one bug fix that we'd ask you to go take right now.
Meet Fisher: the Critter Stack on SQLite
Fisher was purposely built first to be an option for CritterWatch persistence to get people up and going fast without a database server. We also have future product plans for AI related development tools where Fisher will be the persistence layer.
The headline is that there is a new critter in the stack. Fisher is an event store and document database on SQLite — Marten and Polecat's model, in a database that is a file inside your own process.
There is no server to install, nothing to provision, and nothing to keep running alongside your application. Backup is cp. A test fixture needs no container. Fisher uses Microsoft.Data.Sqlite, which ships the SQLite engine itself, so dotnet add package Fisher really is the entire infrastructure story:
services.AddFisher(options =>
{
// Any Microsoft.Data.Sqlite connection string. This one is a file beside the
// application.
options.Connection("Data Source=app.db");
// SQLite has no schemas, so this folds into the table *prefix* instead:
// "main" gives fi_events, anything else gives <name>_fi_events.
options.DatabaseSchemaName = "main";
})
// Run the Weasel migration at startup so the tables exist before the first session.
.ApplyAllDatabaseChangesOnStartup();From there the API is the one you already know — session.Store(), session.Events.StartStream(), LINQ querying, FetchForWriting(). See Getting Started for the full bootstrapping story, and the document and event store guides for what's there.
How real is it? Fisher went from 0.6.0 to 0.9.2 during these sixteen days, and as of 0.9.0 it passes all 36 suites and 309 tests of JasperFx.Events.ComplianceTests — the shared cross-store suite that Marten and Polecat are held to as well. Documents across all four identity types plus strong-typed wrappers, LINQ including joins and grouping, patching, bulk insert, soft deletes, all five projection shapes across all three lifecycles, the async projection daemon, subscriptions, DCB tags, natural keys, event rewriting with masking and stream compacting, both tenancy styles — it's there and it's tested.
Actually, we're likely to flip Fisher to 1.0 by the time you read this as part of the CritterWatch 1.0 release train
It is still pre-1.0, and two limitations are deliberate rather than unfinished. There is no message bus: delivery is a bus integration's job in Fisher exactly as it is on both siblings. And DaemonMode.HotCold is refused rather than quietly accepted, because hot-cold failover means several nodes competing for a leadership lease through a database, and a Fisher store is a file that SQLite does not make safe to share across nodes.
If you want the longer argument for why a single-file store belongs in this stack at all, we wrote it up in the Fisher whitepaper.
One handler, three databases
Again, this is for CritterWatch right now so we can share code between distributions using Marten, Polecat, or Fisher backed persistence, but we think this will be advantageous in the long run by allowing us to eliminate more duplication between the tools
Fisher landing is the visible half of a much larger change. The quieter half is that Marten, Polecat, and Fisher now implement the same shared contracts in JasperFx.Events and JasperFx.Events.Documents, and Wolverine has been taught to code-generate against those contracts instead of against a specific store.
The practical result shipped in Wolverine 6.28.0: Storage.StartStream() and Storage.AppendEvents(), the event-stream counterparts to Storage.Store().
public static class InvoiceHandler
{
// Notice there is no IDocumentSession anywhere in this class
public static StartStream Handle(CreateInvoice command)
=> Storage.StartStream(command.Id, new InvoiceCreated(command.Amount));
public static AppendEvents Handle(ApproveInvoice command)
=> Storage.AppendEvents(command.Id, new InvoiceApproved(command.ApprovedBy));
}That handler is a pure function of its input, unit testable with no database anywhere, and — the actual point — valid against Marten, Polecat, or Fisher without changing a line. It's expressed entirely in terms of JasperFx.Events.IEventOperations, and each store's IntegrateWithWolverine() supplies the right implementation for the active session. Full details in the event side effects guide.
The same is true one level up, for event sourced models. [WriteModel] loads an aggregate's stream with concurrency protection and appends whatever you return:
public static class ShipOrderHandler
{
// [WriteModel] loads the Order's event stream with concurrency protection, hands you
// the current state, and appends whatever events you return back to that same stream.
// Nothing here names an event store -- the same handler is valid on Marten or Polecat.
public static OrderShipped Handle(ShipOrder command, [WriteModel] Order order)
{
return new OrderShipped(DateTimeOffset.UtcNow);
}
}Riding along with that wave:
[DcbModel](6.27.0) — Dynamic Consistency Boundary models, matched by tag across streams, store-agnostic from the start.WolverineFx.Fisher(6.27.0) — the Fisher integration package, ancillary stores included.[FirstOrDefault],[All],[Queryable](6.28.0) — the singleton document[Entity]can't express, every document of a type, and a rawIQueryable<T>escape hatch.- Batched reads (6.28.0) — two or more batchable reads in one handler now resolve in a single database round trip on all three stores, with nothing to turn on.
EventsToAppend(6.29.0) — a store-agnostic return type, replacing three identical-but-store-named ones. Worth knowing why it matters: the previous store-agnostic path was a bareIEnumerable<object>fallback that matched positionally, so in a return tuple the first reference-typed collection won and silently became the appended events.
AfterCommit: middleware that actually runs after the commit
This was added for CritterWatch to have a hook for writing data to update a cache only after the main transaction for a message handler succeeded
This one is small to describe and easy to have gotten wrong in your own code.
Wolverine's After methods run after the handler, not after the transaction. The commit is itself a postprocessor contributed by the persistence provider, and After methods are inserted at the front of that list — so an After method observing a write is observing one that isn't durable yet and may still roll back. There was no supported way to ask for the other side of it.
Wolverine 6.29.0 adds the missing half:
public static class RaiseAlertHandler
{
public static void Handle(RaiseAlert command, IDocumentSession session)
{
session.Events.Append(command.Id, new AlertRaised(command.Reason));
}
// Only runs if the append above actually committed
public static void AfterCommit(AlertLatch latch, RaiseAlert command)
{
latch.MarkRaised(command.Id);
}
}Use AfterCommit / AfterCommitAsync or [WolverineAfterCommit], on message handlers, sagas, and HTTP endpoints; parameters bind exactly as After already does. They deliberately do not run when the commit throws, and they run after the outbox flush — so a message cascaded from an after-commit method is not atomic with the write. Cascade from the handler itself if it has to be. The position is verified per provider: Marten, Polecat, Fisher, EF Core, RavenDb, and CosmosDb each have a codegen test asserting the emitted call lands after that provider's own commit frame.
Documented under "After" is before the commit, which is the sentence we wish we'd written years ago.
Caching aggregate snapshots for FetchForWriting
This is a down payment on a much larger feature set coming later
An opt-in, node-local cache of aggregate snapshots that lets FetchForWriting skip loading the stored snapshot and read only the events after it — think of it as an identity map for aggregates with a lifetime longer than a session. It landed across all three stores in the same wave: Marten 9.27.0, Polecat 5.18.0, and Fisher 0.9.0, from one implementation in JasperFx.Events.Fetching.
opts.Projections.Snapshot<Order>(SnapshotLifecycle.Async);
// Keep up to 1000 recently fetched Order snapshots
opts.Events.CacheAggregatesForWriting<Order>(sizeLimit: 1000);It's off for every aggregate type and enabled per type, because the win is proportional to how often one stream is fetched for writing — real on a hot aggregate under high message volume, pure overhead on an aggregate written once.
The critical property is that the cached snapshot is only ever a baseline. On every call the store still reads the stream version and every event after the cached version, folds those on, and leaves the optimistic concurrency assertion completely untouched. A stale entry costs a larger delta query — never a wrong aggregate, and never a suppressed concurrency exception. That's what makes a deliberately incoherent, node-local cache the right shape here, and why there's no distributed cache option: a distributed cache would reintroduce exactly the round trip this exists to remove.
It's worth the most on Fisher, for a different reason than on its siblings. There the cache removes a snapshot load; Fisher's FetchForWriting folds the whole stream on every call by design, so a hit removes the fold of the entire history.
Docs: Marten and Fisher. One caveat worth repeating from both: a cached baseline is derived state, so event rewriting doesn't reach it any more than it reaches a snapshot. Leave an aggregate whose history you mask unenrolled.
Go take this one: the async projection loader could skip events
This improvement came from a very unusual error condition in real life, but hey, it's nice to be able to say that Marten has been continuously battle hardened against all number of oddball production issues
If you run async projections with an event type filter against a large store, this is the item with a deadline attached. Fixed in Marten 9.25.0.
Marten's adaptive event loader falls back through three strategies when a fetch times out: Normal → SkipAhead → WindowStep. The last one scans the sequence in fixed 10,000-wide windows, and its SELECT is bounded by the window — but it computed the page ceiling against the full high-water mark. The daemon writes that ceiling as durable projection progress, so every matching event between the window ceiling and the high-water mark was skipped and would never be loaded.
This was the ordinary path through that strategy, not an edge case. The window is 10,000 sequence numbers wide, the batch size is 500, and the strategy exists precisely because matching events are sparse — so "returned fewer than BatchSize events", the branch that took the high-water mark, was the expected outcome.
What you'd have seen: a Falling back to WindowStep warning, then the projection advancing to the high-water mark having applied only the handful of events in the first window. No exception, no dead-letter row, the shard reporting itself caught up, and a read model permanently missing most of its data.
The fix does not backfill. If a projection has already been affected, rebuild it.
Reported with a traced mechanism and an executed failing test by @arnelirobles, from barakoCMS — which is also true of two of the three fixes in the next section. That kind of report is worth an enormous amount to us.
Other fixes that don't throw
Bugs that fail loudly get found. These are the ones we go looking for, all shipped in this window:
- Cross-tenant event rewrites under
UseTenantPartitionedEvents(Marten 9.24.0). Under per-tenant event partitioning,seq_idis not unique across tenants. Three operations that rewritemt_eventskeyed theirWHEREonseq_idalone, so the read side was correctly scoped byForTenant(...)while the write escaped it — masking destroyed an uninvolved tenant's payload, and compaction deleted an uninvolved tenant's events. Nothing threw. All three now carry the tenant predicate. Damage already written can't be reversed by the fix; restore from backup or archival storage. - Projection progression deleted by an unescaped prefix
LIKE(Polecat 5.12.0). Rewind, delete-progress, and rebuild teardown all ranDELETE ... WHERE name LIKE @namewith the name plus%._,%, and[are legal in a projection name and all three are T-SQLLIKEmetacharacters, soday_summarymatcheddayXsummaryand a bracketed name did not match itself. Separately, a plain prefix sweep onday_summaryalso tookday_summary_v2's rows. Each half silently destroyed a different projection's progression state. Now matched with exact equality. FetchLatest<T>synthesized a phantom aggregate (Polecat 5.15.0, Fisher 0.7.2). On a stream that exists but holds no eventThandles,FetchLatestreturned a default-constructed aggregate where it should returnnull. SinceFetchLatest<T>(id) is nullis the idiomatic "does this aggregate exist?" probe, the probe was satisfied by any stream id holding events at all — and a default is not neutral: with abool IsActivedefaulting totrue, the phantom read as an active alert for a service that had none.- An
IEventStoreOperationsparameter appended events that were never committed (Wolverine 6.28.0).CanApplyrecognized no event operations type, soAutoApplyTransactionsskipped those chains entirely and the appended events sat in the session's unit of work forever. No exception. This one predates the release it was found in, and affected each store's own event operations types too. - A renamed natural key resolved forever (Polecat 5.12.0).
NaturalKeyProjectiononly ever upserted, so an event changing an aggregate's[NaturalKey]left the previous value behind, still pointing at the same stream — the superseded alias kept resolving, and becausenatural_key_valueis the primary key, the retired value permanently squatted on its slot so no other stream could claim it. - Binary event bodies written to the wrong column (Fisher 0.8.0). A binary event's INSERT named
data_binarybefore the optional metadata columns but bound its value last, so with any metadata column enabled the body landed incorrelation_id. It survived testing because the binary tests enabled no metadata columns and the metadata tests appended no binary event — each half covered, the combination not. Both are exercised together now.
Everything else worth a line
- Oversized SQS messages (Wolverine 6.27.0) — a message over the 256KB cap is rejected by AWS with
SenderFault: true, meaning the identical request will fail identically forever. Wolverine had been treating it as transient and re-queueing, which is why it presented as a flood of identical errors. Now logged once and discarded — and there's new opt-in message fragmentation if you'd rather the message actually got through. - SignalR outgoing coalescing (Wolverine 6.29.0) — buffer and batch outgoing SignalR messages after the outbox, keyed by destination, without routing them through a local queue that becomes a cascade target for its own handlers. See the SignalR guide; browser clients need to handle the new
ReceiveCoalescedMessagesoperation. - Parallel
db-apply/db-assert(Weasel 9.24.0) — a--parallel Nflag runs migrations across many physical databases concurrently, grouped so that inputs targeting the same database stay sequential. Two deliberate behavior changes ride along: no more first-failure abort, anddb-assertnow reports an unexpected exception as that database's failure rather than tearing down the whole run. - Value types beyond the identity (Polecat 5.13.0) — strong-typed wrappers now work as ordinary members, not just as the identity, with four separate defects fixed underneath: scalar
Select, a wrapper binding its own nullable sibling factory as its builder, flat table projection columns falling through tonvarchar(max), and computed indexes that could never be seekable because the index and the predicate were typed differently. Vogen and StronglyTypedId are both under test now. Count(predicate)insideSelect()(Marten 9.24.0) —x.Lines.Count(line => line.IsActive)in a projection is computed by PostgreSQL now instead of by deserializing the whole document client-side.IDocumentCommitListener(Marten 9.28.0, Polecat 5.19.0, Fisher 0.9.2) — the shared post-commit session hook, so a listener can be written once against the contract rather than once per store.
CritterWatch is closing in on 1.0
CritterWatch shipped three release candidates in this window (rc.7 through rc.9), including Application Insights as a full external metrics and trace backend, ghost nodes made distinguishable on every channel state, a Durability tab that holds up at 512-shard scale, and metrics at tenant scale. It also tracks the stack closely: its main build was bumped to JasperFx 2.52.0, Marten 9.28.0, Polecat 5.19.0, and Fisher 0.9.2 the same day those shipped, which is a decent proxy for how well these versions play together.
Upgrading
The dependency line moves as one: Marten 9.28.0, Wolverine 6.29.0, Polecat 5.19.0, Fisher 0.9.2, and Weasel 9.24.0, all built on JasperFx 2.52.0. Take them together rather than one at a time — several of the items above are one shared change landing in four repos, and a couple of the intermediate versions have genuine cross-package floors.
Two upgrade notes to read before you go:
- If you reference
JasperFx.Events.SourceGeneratorexplicitly and reference Polecat or Fisher, you may hitCS0433from two analyzer instances. Both stores now bundle the generator inside their own package; drop the explicit reference. - Binary event serialization moved from each store's namespace to
JasperFx.Events, with the two serializer arguments deliberately reversed so it's a compile error rather than a silent swap. One serializer now works across all three stores.
As always: if something here bit you, or should have and didn't, tell us in Discord or on the relevant GitHub repo. A meaningful share of the fixes above started as somebody's carefully traced bug report, and it shows.


