Releases: JasperFx/fisher
Release list
Fisher 1.0
Fisher is Marten and Polecat for SQLite — event sourcing and document storage with the same Critter Stack API, in a database that is a file inside your own process. No server to install, nothing to provision, nothing to keep running. Backup is cp. Tests need no fixture.
Documentation: fisher.jasperfx.net
dotnet add package FisherWhat 1.0 means
The API is stable and the semantics below are settled. It is not a claim of feature parity with Marten — the gaps that remain are decisions with reasons, not a backlog, and they are listed at the bottom of these notes rather than filed as issues.
Fisher passes all 37 suites and 319 tests of JasperFx.Events.ComplianceTests, the shared cross-store suite Marten and Polecat also enroll in, alongside its own 1,320. Both target frameworks, net9.0 and net10.0.
What's in it
Event store — streams under both identity styles, optimistic concurrency, archiving and tombstoning, FetchForWriting / WriteToAggregate / FetchLatest with an opt-in second-level aggregate write cache, natural keys, DCB tags, and event rewriting: OverwriteEvent, GDPR-style data masking, and one-way stream compacting.
Projections — all five shapes across all three lifecycles: single-stream, multi-stream, event projections, flat-table, and composite. Live aggregation, inline, and an async daemon with dead letters, side-effect seams, and event-emitting projections.
Documents — all four identity types plus strong-typed wrappers, hierarchies, soft delete, numeric revisions, duplicated fields as SQLite VIRTUAL generated columns, user-declared expression indexes, foreign keys, metadata mapping, patching, and bulk insert.
LINQ — Where, ordering, paging, projections, grouping with HAVING, the scalar aggregates, and joins, chained across any number of tables. The surface refuses rather than falling back to client-side evaluation; anything unsupported is refused by name, with the alternative.
Multi-tenancy — conjoined and database-per-tenant, with tenants that appear, suspend and resume at runtime. On SQLite database-per-tenant is arguably the better story: a tenant is a file, and N tenants write concurrently instead of queueing behind one write lock.
Hosting — AddFisher(...), multi-store registration, transaction participants, tracing, and two companion packages: Fisher.AspNetCore (streaming IResult types, ETag/304 handling, a high-water health check) and Fisher.EntityFrameworkCore (a DbContext saving inside Fisher's transaction, and projections whose documents are EF entities).
Since 0.9.2
The documentation site now deploys with the release. docs.yml was workflow_dispatch-only and had never once run, so fisher.jasperfx.net had drifted behind the packages that point at it. It now fires on the same v* tag publish.yml does, so the packages and the documentation for them are cut from one commit.
The README's compliance scoreboard was two waves stale and is now counted off an actual run.
Deliberate gaps
Each is a decision with a reason:
- No message bus. The projection side-effect seam is real and both commit paths bracket their transaction, but the default outbox drops every message. Delivery is a bus integration's job here as it is on both siblings —
Wolverine.Fisheris built in the Wolverine repo. - Exclusive appends are the optimistic ones. SQLite has no row lock, so the faithful equivalent would hold
BEGIN IMMEDIATEfrom fetch to commit and block every other writer for as long as a caller holds a session. Safety is unchanged — the version guard still runs inside the write transaction — but a loser fails instead of waiting. - No hot-cold daemon coordination.
AddAsyncDaemon(DaemonMode.HotCold)refuses rather than quietly running Solo: failover means several nodes competing for a lease through the database, and a Fisher store is a file SQLite does not make safe to share across nodes. - Event rewriting does not reach anything derived from the events. The high-water mark is a sequence and a rewrite does not move it, so an async projection that already passed the event keeps what it derived until it is rebuilt. Marten is the same; it is why masking is a data-at-rest operation and compacting is one-way.
- No ordering on a string-stored enum. The stored form is the member's name, so it would sort alphabetically rather than by declared order. Refused rather than answered wrongly.
- No table partitioning, permanently — SQLite has no equivalent worth having.
Packages
| Package | |
|---|---|
Fisher |
the store |
Fisher.AspNetCore |
streaming results, ETags, health check |
Fisher.EntityFrameworkCore |
EF Core in Fisher's transaction |
0.9.1
A single fix — #102, found from one intermittent CI failure while 0.9.0 was being released.
Non-stale waits on the shards the store registers
WaitForNonStaleProjectionDataAsync — and QueryForNonStaleData behind it — decided it was done from the rows in fi_event_progression rather than from the shards the store registers. A shard that has not started has no row and therefore no sequence to be behind, so it was invisible: with two async projections the wait returned the moment the first one reached the head.
What that costs is the whole point of the call. An application asking for non-stale data was told its data was current while a projection had never run, and read empty documents that look like real answers.
The same rule was broken the other way round, which fixing it surfaced: with events present and no progression rows at all, a store with no async projections waited out its timeout on every call.
Registered shards are now the authority in both directions:
| registered shard, no row | stale |
| registered shard, behind | stale (unchanged) |
| no async projections registered | returns immediately |
| row for a shard nothing registers | ignored — an orphan from a de-registered projection never advances again |
⚠️ Behaviour change
A wait with a registered async projection and no daemon running now waits out its timeout and throws TimeoutException, where it used to return early. That is the honest answer — nothing is going to advance that shard — but it will surface anywhere a test waits for non-stale data without starting a daemon. The message now names the shards that have recorded nothing at all, so "never started" reads differently from "still catching up".
Why it took an intermittent to find
It presented once, as rebuild_and_catch_up_compliance.rebuilding_one_projection_leaves_another_alone on a loaded two-core CI runner — green on the other target framework in the same run and 25/25 locally in isolation. The window is the gap between one shard's first commit and the next shard's.
The regression test is seeded rather than raced, and it needs two registered shards: with no rows at all the old rule waited too, so a store where nothing has run cannot tell the two rules apart. Mutation-checked — reverting to the row-derived rule fails it and the two "returns immediately" facts.
Compatibility
No API changes. Requires JasperFx 2.51.0; Weasel stays at 9.24.0. Everything in 0.9.0 — the aggregate write cache, PendingStreams, and the 2.51.0 bump — is unchanged.
V0.9.2
What's Changed
- IDocumentCommitListener — the shared post-commit session hook (closes #104) by @jeremydmiller in #105
Full Changelog: v0.9.1...V0.9.2
0.9.0
The JasperFx 2.51.0 wave — three issues, one PR each, and Fisher is now green on all 36 shared compliance suites (309 tests) for the first time.
The aggregate write cache (#97)
jasperfx#674 moved IAggregateWriteCache into JasperFx.Events, so Marten, Polecat and Fisher share one second-level snapshot cache behind FetchForWriting instead of a consumer targeting all three writing one per flavour.
opts.Events.CacheAggregatesForWriting<Order>(); // off for every type by default
opts.Events.AggregateWriteCaching.Cache = myOwnCache; // or bring your ownGrade 1 only: the cached snapshot is a baseline. The stream version and every event after the cached one are read on every call, and the optimistic concurrency assertion is untouched — a stale entry costs a larger fold, never a wrong aggregate and never a suppressed concurrency failure.
Worth more here than on either sibling, and for a different reason. 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 history.
Two implementation notes that matter if you are reading the code: nothing is written back at fetch time (an entry published while the caller still holds the instance defeats take-on-read), and the version stored is the one read before the unit of work — because Fisher's inline projection, unlike Marten's, leaves the fetched instance alone.
PendingStreams on the document contract (#96)
jasperfx#673 added IDocumentSessionOperations.PendingStreams — the StreamActions a session has queued and not yet committed, for a listener or pre-commit hook deciding something from what the session is about to write, without naming a store. Fisher's forward is a snapshot and includes the session's tenant scopes, because those commit in the same transaction.
session.Events.PendingStreams is unchanged and remains the native spelling.
JasperFx 2.51.0 (#98)
The floor moves to 2.51.0. The one requirement was fixture-side: DocumentComplianceConfig.StreamIdentity (jasperfx#672) replaced an inference Fisher's document compliance fixture was making.
Also in this release
- #95 closed — the binary-event column swap it recorded shipped fixed in 0.8.0. If you ran 0.7.x with your own
IEventBinarySerializerand any of the fourEnable*metadata options, rows written then have their BLOB and correlation id transposed; there is no automatic repair, and 0.8.0 onward is unaffected.
Known issue
#102 — WaitForNonStaleProjectionDataAsync (and QueryForNonStaleData behind it) can return while an async shard that has not started yet is still stale, because it compares against the progression rows that exist rather than the shards that are registered. Pre-existing since the daemon landed rather than new in this release; found from an intermittent CI failure while preparing it, and fixed next.
Compatibility
No breaking changes. Requires JasperFx 2.51.0; Weasel stays at 9.24.0.
Fisher 0.8.0
Consumes JasperFx 2.50.0 and moves Fisher's binary event serialization onto the shared contracts.
⚠️ Upgrade JasperFx and Fisher together
Fisher 0.7.2 with JasperFx 2.50.0 does not compile. JasperFx 2.50.0 adds an Events accessor to IDocumentSessionOperations, and on 0.7.2 that reaches IDocumentSession down two unrelated inheritance branches with neither hiding the other — so every session.Events call site fails with:
error CS0229: Ambiguity between 'IDocumentSessionOperations.Events' and 'IQuerySession.Events'
0.8.0 fixes it by re-declaring the member on Fisher's own IDocumentSession. This release requires JasperFx >= 2.50.0, but NuGet cannot express the other direction — so if you are on 0.7.2, do not take JasperFx 2.50.0 on its own.
🐛 Fixed: binary event bodies could be written to the wrong column (shipped in 0.7.x)
A binary event's INSERT named data_binary before the optional metadata columns but bound its value last. With any of EnableCorrelationId / EnableCausationId / EnableHeaders / EnableUserName turned on, every value from data_binary onward shifted by one: the event body landed in correlation_id and the correlation id in data_binary.
It survived review because the comment at the call site asserted the invariant it violated, and it survived testing because the binary tests enabled no metadata columns and the metadata tests appended no binary event — each half was covered, the combination was not. Both are exercised together now.
Affected rows are unreadable on the event body; the row and the stream are otherwise intact, so it presents as a deserialization failure rather than an append error.
💥 Breaking: binary serialization moved to the JasperFx contracts
IEventBinarySerializer and [BinaryEvent] now come from JasperFx.Events instead of Fisher.Events, so one serializer implementation works across Marten, Polecat and Fisher. Migrating:
was (Fisher.Events) |
now (JasperFx.Events) |
|---|---|
Serialize(object eventBody, Type eventType) |
Serialize(Type type, object data) — argument order reversed |
Deserialize(byte[] data, Type eventType) |
Deserialize(Type type, byte[] data) |
Events.BinarySerializer = … |
Events.DefaultBinarySerializer = … |
| — | Events.UseBinarySerializer<TEvent>(…) for per-type registration |
[BinaryEvent] on class/struct/interface |
class/struct only |
The reversal is a compile error rather than a silent swap, because the parameter types also exchange places.
Storage: per-row dispatch, and no migration to opt in
data_binary is now an unconditional nullable BLOB and data stays TEXT NOT NULL, with binary rows carrying a {} placeholder. Whether a row is binary is decided per row by data_binary IS NULL, never by the event type.
Both differ from 0.7.x deliberately:
- An existing store upgrades with a single
ADD COLUMN. Weasel.Sqlite can add a nullable column in place but needs a full table recreation to change nullability — keepingdataNOT NULL is what avoids rewriting the events table. - Per-row dispatch is what makes marking a type
[BinaryEvent]an in-place change: rows already written stay JSON and still read. Type-based dispatch would send those down the binary path, where a null BLOB is an exception or an all-defaults event.
data nullable with real NULLs in binary rows; restoring NOT NULL would be a table recreation that fails on them. Given the feature is eight days old, pre-1.0, and requires writing your own serializer, no migration path was built. If you have such a store, raise an issue before upgrading.
Also
- jasperfx#669 —
IDocumentReadOperations.EventsandIDocumentSessionOperations.Eventsimplemented onFisherSession, so a session you open yourself can reach the event store store-agnostically. - Serializer registration is no longer order-sensitive: registering an event type before its serializer now defers the refusal to the append, where it is actionable.
- Enrolled in the shared
BinaryEventSerializationComplianceandDocumentSessionEventsCompliancesuites.
Tests: Fisher.Tests 1265 passed / 0 failed on net9.0 and net10.0.
Full changelog: v0.7.2...v0.8.0
0.7.2
Three issues, on JasperFx 2.49.0 / Weasel 9.24.0.
⚠️ One behaviour change
The on-demand document table path now honours AutoCreate.None (#81). Fisher creates a document type's table on demand, the first time something reads or writes one. Under AutoCreate.None that path now checks instead of creating, and throws naming the document type if its table is missing — matching HiloSequence, which already declined, so the store no longer disagrees with itself.
If you deploy with AutoCreate.None, apply your schema out of band — and re-apply it after registering a new projection, since that registration is what maps the snapshot's document type. An existing table is not an error, so a store that already applies its schema properly is unaffected.
Fixes
FetchLatest<T> no longer synthesises a phantom aggregate (#88). On a stream that exists but holds no event T handles, FetchLatest returned a default-constructed aggregate where Marten and Polecat return null — the polecat#463 class, found by the CritterWatch port.
That matters because FetchLatest<T>(id) is null is the idiomatic "does this aggregate exist?" probe, so the probe was satisfied by any stream id holding events at all. And a default is not neutral: with a bool IsActive defaulting to true, the phantom read as an active alert for a service that had none.
An Inline-projected aggregate is now read from its projected document, which is what the write side already believed — the inline projection screens out streams it does not own, which is why no row was ever written for them.
New
LoadAsync<T>(object) (#89), the document contract's eighth operation from jasperfx#665. Public on IQuerySession alongside LoadAsync<T, TId>, matching Marten and Polecat so a consumer moving between the stores meets one spelling. It resolves a strong-typed wrapper, a raw value the wrapper is over, and the four canonical identity types alike.
The four canonical overloads are more specific and still win overload resolution, so no existing call site changes.
Compliance
32 suites, 275 tests, all green — 230 event and 45 document. 2.49.0 added no new suite file but widened DocumentLoadAndStoreCompliance by three tests for the new member.
Full suite 1250 green on net9.0 and net10.0, plus 36 in Fisher.AspNetCore.Tests and 13 in Fisher.EntityFrameworkCore.Tests.
0.7.1
A dependency bump, with one piece of code coming back out.
JasperFx 2.48.0
All four JasperFx packages move together. Fisher 0.7.1 requires JasperFx 2.48.0 — see below for why that is a real floor rather than a preference. Weasel stays at 9.24.0, which is still current.
2.48.0 adds no compliance suite and changes no existing suite file, checked by diffing the package contents rather than assumed. All 32 suites and 272 tests remain enrolled and green, with enrollment re-verified by extracting the package's *Compliance classes and diffing against the enrolled list.
The fisher#72 workaround is removed
jasperfx#663 shipped in 2.48.0. That is the upstream half of the #72 fix released in 0.7.0: StreamAction.Append(graph, string, …) appended straight to the backing list where the Guid overload beside it went through AddEvent, so every event appended to a string-identified stream reached an inline projection with an empty StreamKey. Upstream now routes both string overloads through AddEvents.
Fisher had been stamping the identity in its own append planner. That workaround is gone, following the standing rule that a workaround is filed upstream and taken back out when the fix ships — the same cycle FisherCommandBuilder completed for weasel#424.
Nothing about the behaviour changes. It was verified by deleting the stamping and running the full suite against 2.48.0, including the test written to fail without it; that test now guards the upstream fix rather than a Fisher workaround, and still pins both the string and Guid halves so a later release cannot silently change which one is covered.
This removal is what makes 2.48.0 a hard floor: on an older JasperFx, IEvent.StreamKey would be blank inside an inline projection again. NuGet enforces it.
The bundled source generator moves with it
Fisher has carried JasperFx.Events.SourceGenerator inside its own package since 0.7.0, so 2.48.0's batch of source-generator fixes now reaches consumers through Fisher. One of them changes the generator to report unregistered published types instead of skipping them in silence; it emits nothing against Fisher itself, confirmed by diffing full-build warning counts code for code against 2.47.0.
Verified
Release build, all three suites, both target frameworks: 1228 + 36 + 13, zero failures on each.
Full changelog: v0.7.0...v0.7.1
0.7.0
Six issues, and three of them are the kind that do not throw. If you are upgrading, read the first two.
Fixed: IEvent.StreamKey was empty inside an inline projection (#72)
The stream key was persisted correctly and arrived blank on the events handed to an inline projection. Reading e.StreamKey is the normal way a string-identified projection learns which entity it is projecting, so a projection doing that wrote a document with an empty key field — and nothing threw. The first visible symptom was a query returning nothing, which reads as "the projection did not run" rather than "one field was blank".
The cause is upstream and asymmetric: StreamAction.AddEvent stamps StreamId/StreamKey/TenantId, and StreamAction.Append(graph, Guid, …) goes through it — but StreamAction.Append(graph, string, …) appends straight to the backing list and does not. PrepareEvents does not close the gap either; it sets TenantId, Timestamp, Version and Sequence, never the stream identity. Filed as JasperFx/jasperfx#663.
Fisher now stamps the identity in its own append planner, which is idempotent and stays correct when that ships. The async daemon was never affected — it hydrates events from the row.
Fixed: the nupkg did not carry the projection dispatcher generator (#73)
Projection dispatch is source-generated with no runtime fallback, and the generator ships as a development dependency that does not flow transitively. Marten and Polecat both bundle it inside their own package; Fisher did not. So a consumer referencing only Fisher never ran it.
The absence was not a build failure. Removing the generator removes generated partials that nothing hand-written references, so the consuming assembly compiled clean and then threw on its first projected event:
JasperFx.Events.Projections.InvalidProjectionException :
No source-generated dispatcher found for EventProjection ...
On a deployment that is a service that boots and dies on its first message. The package now carries analyzers/dotnet/cs/JasperFx.Events.SourceGenerator.dll, so you can drop the explicit analyzer reference if you added one as a workaround.
The generator still runs in the assembly that defines the aggregate or projection, so that assembly is the one that has to reference Fisher.
Fixed: a read before the first write threw no such table (#74)
Query<T>() or LoadAsync<T> against a document type nothing had ever written failed with a raw SQLite error, where Marten and Polecat provision the table and return an empty result. Table creation was reached from the write path only.
This is what every cold start does — resolving a cache before anything has populated it, listing a collection on a fresh install — and it is asymmetric in the worst direction: it works on a warm database and fails on a fresh one, so it passes in development and fails on first deploy. The workaround was a hand-maintained list of Schema.For<T>() lines kept in sync across the live registration and every test fixture.
Reads now provision exactly as the first write does. Query<T>(), the scalar terminals, joins, LoadAsync, LoadManyAsync, CheckExistsAsync, LoadJsonAsync and MetadataForAsync are all covered.
Two things unchanged: an enlisted session still asserts rather than creating, on reads as on writes — running a migration on a second connection from inside your transaction would deadlock against your own write lock, so it throws by name instead. And a type with registered projection storage is skipped, since its rows are not in a Fisher document table.
Fixed: Projections.Add<T>(lifecycle) under-registered (#76)
The issue reads as "the overload is missing". It was not — it was inherited from ProjectionGraph, compiled, and was worse than missing: it went straight to All.Add, bypassing registration, so it registered neither the projection's event types nor its published document type. The table was never created, and both failures were silent at registration.
opts.Projections.Add<OrdersByCustomer>(ProjectionLifecycle.Async);
opts.Projections.Add<OrdersByCustomer>(ProjectionLifecycle.Async, o => o.BatchSize = 1000);now means exactly what the instance form means. Its constraint is deliberately weaker than the inherited one, so a bare IProjection can be registered by type too.
Added: StoreOptions.RegisterValueType<T>() (#75)
Fisher discovers strong-typed identifiers from their shape, so this is never required. It exists so store configuration is portable — the same block should read identically whichever store it is pointed at, rather than dropping one line for Fisher and explaining the omission in a comment. Same argument as polecat#459.
opts.ConfigureSerialization(EnumStorage.AsString, Casing.CamelCase);
opts.Events.StreamIdentity = StreamIdentity.AsString;
opts.RegisterValueType<AlertId>();It is not an accepted no-op, and that is the point of using it. Discovery has to treat "not a wrapper" as the ordinary answer; naming a type here is an assertion that it is one, so the same answer becomes a configuration error — reported with the type named, rather than surfacing later as has no identity member.
Added: per-tenant dead letter counts (#77)
IEventDatabase.FetchDeadLetterCountsAsync(tenantId) was not overridden, so it landed on JasperFx's default and threw NotSupportedException for a non-null tenant while the store-global overload beside it worked. A monitoring console meets that as soon as it renders per-tenant badges per shard.
var forBlue = await store.Database.FetchDeadLetterCountsAsync("blue");A null tenant stays store-global and leaves TenantId null, so a consumer keying by {ProjectionName}:{ShardKey} can tell "every tenant" from "the default tenant".
Behaviour change: session.Store(deadLetterEvent) now throws
On Marten and Polecat a DeadLetterEvent is also an ordinary document, so that call lands it in the very table QueryDeadLetterEventsAsync reads. In Fisher it is event store infrastructure with its own table and its own write path — so the same call compiled, succeeded, wrote a fi_doc_deadletterevent row, and the dead-letter query never saw it.
Fisher's arrangement is the better one, and the divergence was still silent in the direction that hurts: ported code kept working and quietly stopped recording anything. It now throws, naming StoreDeadLetterEventAsync — which is what the daemon does and what ports back to either sibling unchanged.
Also
- Docs corrected across nine pages for the two behaviour changes above — the five that told you to reference the source generator yourself, and the four that warned a query against a never-written type would fail.
Wolverine.Http.Fishermoved to the wolverine repo, where the code would live. It is unblocked —Wolverine.Fisheralready carries every base class it needs.- #81 filed: the on-demand table path does not honour
AutoCreate.None, where the Hi-Lo path does. Pre-existing on the write path; the read path added in #74 inherits it, and the current behaviour is pinned so either resolution is deliberate.
Full changelog: v0.6.0...v0.7.0
0.6.0
ConfigureFisher(...) — the lambda form of IConfigureFisher (#70)
Four extension methods on IServiceCollection, mirroring Marten's ConfigureMarten and Polecat's ConfigurePolecat — a primary and a targeted pair, each with and without the container:
services.ConfigureFisher(options => ...);
services.ConfigureFisher((serviceProvider, options) => ...);
services.ConfigureFisher<IReportingStore>(options => ...);
services.ConfigureFisher<IReportingStore>((serviceProvider, options) => ...);This is the surface an integration package uses to layer its own StoreOptions contributions onto a store somebody else registered. Wolverine.Fisher's ancillary-store support is the caller, and having the three stores present the same shape here is what JasperFx/wolverine#3907 asked for.
Fixed: a contribution registered against the closed IConfigureFisher<T> silently did nothing
Fisher resolves IConfigureFisher and filters by the contribution's own interfaces; Marten and Polecat resolve the closed IConfigure*<T> directly. So code ported from either — services.AddSingleton<IConfigureFisher<IMyStore>, MyConfiguration>() — registered against a service type GetServices<IConfigureFisher>() does not return, because the container matches on the service type a registration named rather than on what it implements. A contribution that compiled, registered, and never ran.
Both registration styles are now swept and deduplicated by reference, so a contribution registered against both service types is still applied once.
If you register IConfigureFisher<T> implementations this way, they will start running on 0.6.0. That is the intent — but it is a behaviour change, so it is worth checking that what they configure is what you meant.
Also
- Docs: Bootstrapping, Multiple Stores, and the migration-guide name table.
Full changelog: 1fe0468...v0.6.0