Marten just gained two concrete query plans for the raw event stream fetches, plus the two ASP.NET Core IResult types that sit on top of them. Polecat already has the matching IQueryPlan<T> / IBatchQueryPlan<T> / QueryListPlan<T> abstractions and the matching Stream* result family, so this is a parity port rather than a design exercise.
Upstream: JasperFx/marten#5053 (supersedes marten#5043, original plans by @uniquelau).
Part 1 — FetchStreamStatePlan and FetchStreamPlan
Marten added these in the Marten namespace as the event-side siblings of QueryListPlan<T>:
FetchStreamStatePlan — a stream's StreamState? by stream identity; null when the stream does not exist.
FetchStreamPlan — a stream's raw IReadOnlyList<IEvent> by stream identity; empty list when the stream does not exist, carrying FetchStream's optional version / timestamp / fromVersion arguments.
Both implement both IQueryPlan<T> and IBatchQueryPlan<T>, and both offer Guid streamId / string streamKey constructor overloads.
// Standalone
var state = await session.QueryByPlanAsync(new FetchStreamStatePlan(streamId));
var events = await session.QueryByPlanAsync(new FetchStreamPlan(streamId, version: 5));
// Batched — one round trip
var batch = session.CreateBatchQuery();
var stateFetcher = batch.QueryByPlan(new FetchStreamStatePlan(streamId));
var eventsFetcher = batch.QueryByPlan(new FetchStreamPlan(streamId));
await batch.Execute();
Implementing both interfaces matters beyond convenience: through Wolverine's fetch-specification feature, a plan that implements only IBatchQueryPlan<T> produces uncompilable generated code. Shipping the pair with both removes the trap.
Prerequisite on the Polecat side
Polecat.Batching.IBatchedQuery has QueryByPlan<T>, Load/LoadMany/CheckExists, EventsExist and FetchForWritingByTags — but no Events surface, i.e. no equivalent of Marten's IBatchEvents with FetchStreamState / FetchStream batched fetchers. Polecat.Events.QueryEventStore already has FetchStreamAsync and FetchStreamStateAsync, so only the batched half is missing.
So the port splits in two:
- add the batched event fetchers to
IBatchedQuery (FetchStreamState and FetchStream, Guid and string overloads), then
- add the two plans on top.
If step 1 is more than we want to take on right now, the plans could ship implementing IQueryPlan<T> only — but note the Wolverine codegen trap above makes a partial implementation actively worse than none for anyone routing these through a handler's Load. Better to do both or neither.
Part 2 — StreamEventState and StreamEvents in Polecat.AspNetCore
Polecat.AspNetCore already ships StreamOne<T>, StreamMany<T>, StreamAggregate<T>, StreamPaged<T>, StreamPagedByCursor<T>. Marten added two more, backed by the plans above:
app.MapGet("/orders/{id:guid}/state", (Guid id, IQuerySession s) => new StreamEventState(s, id));
app.MapGet("/orders/{id:guid}/events", (Guid id, IQuerySession s) => new StreamEvents(s, id));
Both implement IResult and IEndpointMetadataProvider, take Guid / string / pre-built-plan constructors, and delegate the body write to new WriteStreamState / WriteEvents extension methods on IQuerySession.
Two things worth carrying over verbatim
1. Neither writes the framework's own types to the wire, because neither can. StreamState.AggregateType and IEvent.EventType are System.Type, and System.Text.Json refuses to serialize those:
NotSupportedException: Serialization and deserialization of 'System.Type' instances
is not supported. Path: $.AggregateType.
This was verified against a real store serializer, not assumed — a result that serialized them naively fails at runtime for every STJ user. Marten introduced StreamStateResponse and EventResponse DTOs: the aggregate type reduces to its simple name, and IEvent's assembly-qualified DotNetTypeName is deliberately kept off the wire, leaving EventTypeName as the client-side discriminator. Polecat needs the same two DTOs, and ideally the same property names so clients can move between the two stores.
2. Empty streams are ambiguous. FetchStream yields an empty list both for a stream that does not exist and for a filter that excludes every event, and the two cannot be told apart. StreamEvents exposes an OnEmptyStatus defaulting to 404 (matching the other single-resource results); set it to 200 to return an empty array, which is what you want when paging forward with fromVersion and running off the end is expected.
Implementation detail worth copying: serialization buffers through an ArrayBufferWriter<byte> and ISerializer.WriteTo, so the JSON never round-trips through a .NET string, and Content-Length is set on both responses.
Test and docs coverage to mirror
Marten's side landed 8 tests for the plans (standalone and batched, Guid and string identity, missing stream, version cap) and 12 Alba tests for the results (both types, plan constructor, OnEmptyStatus opt-out, Content-Length, serialized event body, OpenAPI metadata), plus docs sections in documents/aspnetcore.md and events/querying.md.
Marten just gained two concrete query plans for the raw event stream fetches, plus the two ASP.NET Core
IResulttypes that sit on top of them. Polecat already has the matchingIQueryPlan<T>/IBatchQueryPlan<T>/QueryListPlan<T>abstractions and the matchingStream*result family, so this is a parity port rather than a design exercise.Upstream: JasperFx/marten#5053 (supersedes marten#5043, original plans by @uniquelau).
Part 1 —
FetchStreamStatePlanandFetchStreamPlanMarten added these in the
Martennamespace as the event-side siblings ofQueryListPlan<T>:FetchStreamStatePlan— a stream'sStreamState?by stream identity; null when the stream does not exist.FetchStreamPlan— a stream's rawIReadOnlyList<IEvent>by stream identity; empty list when the stream does not exist, carryingFetchStream's optionalversion/timestamp/fromVersionarguments.Both implement both
IQueryPlan<T>andIBatchQueryPlan<T>, and both offerGuid streamId/string streamKeyconstructor overloads.Implementing both interfaces matters beyond convenience: through Wolverine's fetch-specification feature, a plan that implements only
IBatchQueryPlan<T>produces uncompilable generated code. Shipping the pair with both removes the trap.Prerequisite on the Polecat side
Polecat.Batching.IBatchedQueryhasQueryByPlan<T>,Load/LoadMany/CheckExists,EventsExistandFetchForWritingByTags— but noEventssurface, i.e. no equivalent of Marten'sIBatchEventswithFetchStreamState/FetchStreambatched fetchers.Polecat.Events.QueryEventStorealready hasFetchStreamAsyncandFetchStreamStateAsync, so only the batched half is missing.So the port splits in two:
IBatchedQuery(FetchStreamStateandFetchStream, Guid and string overloads), thenIf step 1 is more than we want to take on right now, the plans could ship implementing
IQueryPlan<T>only — but note the Wolverine codegen trap above makes a partial implementation actively worse than none for anyone routing these through a handler'sLoad. Better to do both or neither.Part 2 —
StreamEventStateandStreamEventsinPolecat.AspNetCorePolecat.AspNetCorealready shipsStreamOne<T>,StreamMany<T>,StreamAggregate<T>,StreamPaged<T>,StreamPagedByCursor<T>. Marten added two more, backed by the plans above:Both implement
IResultandIEndpointMetadataProvider, takeGuid/string/ pre-built-plan constructors, and delegate the body write to newWriteStreamState/WriteEventsextension methods onIQuerySession.Two things worth carrying over verbatim
1. Neither writes the framework's own types to the wire, because neither can.
StreamState.AggregateTypeandIEvent.EventTypeareSystem.Type, and System.Text.Json refuses to serialize those:This was verified against a real store serializer, not assumed — a result that serialized them naively fails at runtime for every STJ user. Marten introduced
StreamStateResponseandEventResponseDTOs: the aggregate type reduces to its simple name, andIEvent's assembly-qualifiedDotNetTypeNameis deliberately kept off the wire, leavingEventTypeNameas the client-side discriminator. Polecat needs the same two DTOs, and ideally the same property names so clients can move between the two stores.2. Empty streams are ambiguous.
FetchStreamyields an empty list both for a stream that does not exist and for a filter that excludes every event, and the two cannot be told apart.StreamEventsexposes anOnEmptyStatusdefaulting to404(matching the other single-resource results); set it to200to return an empty array, which is what you want when paging forward withfromVersionand running off the end is expected.Implementation detail worth copying: serialization buffers through an
ArrayBufferWriter<byte>andISerializer.WriteTo, so the JSON never round-trips through a .NET string, andContent-Lengthis set on both responses.Test and docs coverage to mirror
Marten's side landed 8 tests for the plans (standalone and batched, Guid and string identity, missing stream, version cap) and 12 Alba tests for the results (both types, plan constructor,
OnEmptyStatusopt-out,Content-Length, serialized event body, OpenAPI metadata), plus docs sections indocuments/aspnetcore.mdandevents/querying.md.