Skip to content

The search box knows all the secrets -- try it!

Fisher is part of the Critter Stack ecosystem.

JasperFx Logo JasperFx provides formal support for Fisher and other Critter Stack libraries. Please check our Support Plans for more details.

Querying Events ​

Reading a stream ​

cs
var events = await session.Events.FetchStreamAsync(streamId);
var events = await session.Events.FetchStreamAsync(streamId, version: 10);
var events = await session.Events.FetchStreamAsync(streamId, fromVersion: 5);
var events = await session.Events.FetchStreamAsync(streamId, timestamp: cutoff);

var state = await session.Events.FetchStreamStateAsync(streamId);
// state.Version, state.Created, state.LastTimestamp, state.AggregateType, state.IsArchived

Loading one event ​

cs
var @event = await session.Events.LoadAsync(eventId);

Aggregating ​

cs
var order = await session.Events.AggregateStreamAsync<Order>(streamId);
var order = await session.Events.AggregateStreamAsync<Order>(streamId, version: 10);
var order = await session.Events.AggregateStreamToLastKnownAsync<Order>(streamId);

See Live Aggregations.

Unknown event types ​

TIP

Stream reads skip an unresolvable dotnet_type unconditionally, so a deployment can still read events it does not know about.

The async daemon must not do that — silently skipping one would leave the projection permanently wrong — so it honours SkipUnknownEvents and otherwise throws. Two different policies, each right for its caller.

A body that will not parse is a different matter and is never skipped by a stream read: it surfaces as Fisher.Exceptions.EventDeserializationFailureException, carrying the sequence, the stored event type alias and the serializer's own exception. Only the daemon can be told to skip one, through SkipSerializationErrors.

Querying event metadata ​

QueryEventsAsync pages over fi_events filtering on the row's columns:

cs
var page = await session.Events.QueryEventsAsync(new EventQuery
{
    StreamId = streamId.ToString(),
    EventTypeNames = ["OrderPlaced"],
    TimestampFrom = cutoff,
    CorrelationId = correlationId,
    PageNumber = 1,
    PageSize = 50
});

page.Events;
page.TotalCount;

Two things in it are load-bearing:

TIP

A StreamId filter is parsed and re-rendered under Guid identity. fi_events.stream_id holds the lowercase canonical form and SQLite's default collation is case-sensitive, so binding a caller's uppercase Guid string directly would match nothing — and a monitoring tool would render an existing stream as empty.

TIP

The three metadata filters are gated on the options that create their columns. correlation_id, causation_id and user_name do not exist unless the matching Enable* option is on, so an ungated filter would be no such column rather than an empty result. An unavailable filter is ignored, which is what the query shape asks for and what Polecat does.

The count is a second statement, not count(*) over () — a window function returns no row at all for a page past the end, and "page 9 of a 3-page result" is exactly when a tool most needs the real total.

Filtering by tags ​

Two spellings, and they are alternatives rather than a combination — supplying both is an ArgumentException.

TagValues is the lossy name/value form. Entries are AND'd: an event matches when it carries every named tag at the given value, and that selection is then AND'd with every other filter, so a tag query keeps paging and a truthful TotalCount.

cs
var page = await session.Events.QueryEventsAsync(new EventQuery
{
    // Either the registered table suffix or the tag type's CLR name, case-insensitively.
    TagValues = { ["shipment"] = shipmentId.ToString(), ["CarrierCode"] = "Acme" },
    EventTypeNames = ["CargoLoaded"],
    PageSize = 50
});

TagConditions is the rich CLR-typed form, and its conditions are OR'd. Reach for it when the caller holds the tag types; reach for TagValues when it holds only a name — an HTTP query string, a CritterWatch console, the event-query --tags flag.

TIP

An unregistered tag name is refused, not answered empty. "That tag type does not exist here" and "no event carries that tag" must not read alike, or a caller with a typo concludes the events are gone. The message lists what is registered.

WARNING

A string-valued tag is matched case-insensitively, and pays a scan of its tag table for it. A Guid or numeric tag has a canonical stored form, so the value you pass is normalised to it and the tag table's primary key serves the lookup. A string tag's stored text is your own casing, so the comparison carries collate nocase — and SQLite reaches an index only under the index's own collation. Note also that SQLite's NOCASE folds ASCII only.

Querying event bodies ​

QueryEventsAsync filters on metadata. To filter on what an event says, name its type:

cs
var events = await session.Events.QueryEventDataAsync<OrderPlaced>(e => e.Total > 1000m);

An event body is a JSON document in a TEXT column, structurally identical to a stored document, so the same member locators apply verbatim — including the strftime wrapper for a timestamp inside a body.

TIP

There is no DocumentMapping involved, and that is not laziness. Most event types have no identity member, and asking for a mapping would register the event type as a document — giving it a table in the next migration.

WARNING

A body member called Id is not the event's own id column. That column is the event's identity, so resolving to it would compare against the wrong column and return rows rather than an error.

The type filter uses the short type alias, not dotnet_type — short and stable where the other is assembly-qualified and brittle across a rename.

WARNING

This is a scan, and honestly so: there is no index over fi_events.data. Expression indexes are the mechanism if one ever needs to be fast, and they would apply here unchanged.

A binary event is refused by name — data is null for those rows, so the query would match nothing and report that as an answer.

The read-only event store ​

cs
await using var events = store.OpenReadOnlyEventStore();

TIP

FisherReadOnlyEventStore owns its session lifetime rather than capturing one — the one divergence from Polecat here, and it is dialect-forced. Polecat returns QuerySession().Events directly, and since the interface is not IDisposable, nothing ever disposes that session. A Fisher session caches its connection for its whole lifetime, so the same shape would leak a pooled connection against a single database file on every call — to a method whose caller is a polling monitoring tool.

Explorer reads ​

For a monitoring console:

cs
var streams = await eventStore.GetRecentStreamsAsync(…);
var metadata = await eventStore.GetStreamMetadataAsync(streamId);

These are on the IEventStore tooling interface, implemented explicitly on DocumentStore — so you cast to reach them, which is the point of implementing them explicitly. See Diagnostics.

TIP

Rows are materialised inside the resilience pipeline, not streamed out of it. A retried SQLITE_BUSY re-executes the whole delegate, so yielding a live reader to the caller would let a retry resume against a connection the previous attempt had already disposed.

Released under the MIT License.