feat(#370): FetchStreamStatePlan / FetchStreamPlan + batched event fetches + StreamEventState / StreamEvents (marten#5053 parity) - #372
Merged
Conversation
…tches + StreamEventState / StreamEvents (marten#5053 parity) Parity port of marten#5053 (supersedes marten#5043, original plans by @uniquelau). Polecat already had the matching IQueryPlan<T> / IBatchQueryPlan<T> / QueryListPlan<T> abstractions and the Stream* result family, so this is a port rather than a design exercise. Both halves land here rather than plans-only. Through Wolverine's fetch-specification feature a plan implementing only IBatchQueryPlan<T> produces uncompilable generated code, so a partial implementation would have been actively worse than none. Part 1 -- the batched event surface, which did not exist. IBatchedQuery had QueryByPlan, Load/LoadMany/CheckExists, EventsExist and FetchForWritingByTags but no Events surface at all. New IBatchEvents, reached as batch.Events, with FetchStreamState and FetchStream in Guid and string overloads, FetchStream carrying the same optional version/timestamp/fromVersion filters as the standalone fetch. The two new batch items compose their SQL from the SAME canonical projections (PcStreamsRowReader.SelectColumns, PcEventsRowReader.ComposeSelectColumns) and hydrate through the SAME readers as the standalone path, with the same per-batch hoists, so batched and standalone cannot drift apart across a schema migration. The batch ensures the event store schema when it holds an event item (#219 create-on-first-use), flagged rather than eager so a document-only batch pays nothing. Part 2 -- FetchStreamStatePlan and FetchStreamPlan, each implementing both IQueryPlan<T> and IBatchQueryPlan<T>, each with Guid/string constructor overloads. Part 3 -- StreamEventState and StreamEvents in Polecat.AspNetCore, the event-side siblings of StreamAggregate<T>, plus WriteStreamState / WriteEvents extensions on IQuerySession. Neither writes the framework's own types to the wire because neither can: StreamState.AggregateType and IEvent.EventType are System.Type and STJ refuses to serialize those, so StreamStateResponse / EventResponse project them down -- aggregate type to its simple name, IEvent's assembly-qualified DotNetTypeName deliberately off the wire with EventTypeName as the discriminator. Property names match Marten's DTOs so clients can move between stores. FetchStream yields an empty list both for a missing stream and for a filter excluding every event, so StreamEvents.OnEmptyStatus defaults to 404 with 200 as the opt-out for fromVersion paging. Bodies buffer through an ArrayBufferWriter<byte> and set Content-Length. Also fixed, because the port surfaced it and it would have shipped a dead wire field: StreamState.AggregateType was NEVER populated. pc_streams.type has always been projected and never read back, and the JasperFx path that would register the alias (StreamAction.PrepareEvents -> AggregateAliasFor) is one Polecat's QuickAppend closed-shape writer never goes through, so EventGraph.AggregateTypeFor could never resolve it. New EventGraph.TryResolveAggregateType resolves against the registered projections first (the strategy the event store explorer already used), caches the hit, and returns null rather than throwing for an alias this deployment does not know -- a stream tagged by a deployment that knew a type this one does not must still report its version and timestamps. This fixes FetchStreamStateAsync for every caller, not just the new endpoint. Tests: batch_event_fetching (the surface directly, including a dedicated timestamp case and an interleaved batch where a mis-ordered result set surfaces as a cross-wired answer), fetching_stream_query_plans (each plan standalone and batched, missing stream, version cap, fromVersion, shared round trip, plus a string-identity store), and 12 Alba tests over real Minimal API endpoints. Docs in batched-queries.md, events/querying.md and documents/aspnetcore.md. Full suite green: 1576 passed on net10, AspNetCore 65, EF Core 37. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ETF4fye5MJk3kGX5kunrRk
This was referenced Jul 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Parity port of marten#5053 (which supersedes marten#5043, original plans by @uniquelau). Polecat already had the matching
IQueryPlan<T>/IBatchQueryPlan<T>/QueryListPlan<T>abstractions and theStream*result family, so this is a port rather than a design exercise.Closes #370.
Both halves are here rather than plans-only. The issue calls that out and it is worth repeating: through Wolverine's fetch-specification feature, a plan implementing only
IBatchQueryPlan<T>produces uncompilable generated code, so a partial implementation would have been actively worse than none.Part 1 — the batched event surface (the prerequisite)
Polecat.Batching.IBatchedQueryhadQueryByPlan,Load/LoadMany/CheckExists,EventsExistandFetchForWritingByTags, but noEventssurface at all — no batched counterpart toQueryEventStore.FetchStreamAsync/FetchStreamStateAsync.IBatchEvents, reached asbatch.Events, withFetchStreamStateandFetchStreaminGuidandstringoverloads.FetchStreamcarries the same optionalversion/timestamp/fromVersionfilters as the standalone fetch.PcStreamsRowReader.SelectColumns,PcEventsRowReader.ComposeSelectColumns) and hydrate through the same readers as the standalone path, so a batched fetch and a standalone one cannot drift apart across a schema migration. Same per-batch hoists too — metadata ordinals computed once, single-slot type→mapping cache,StreamIdentityspecialization picked once rather than per row.IBatchedQueryis a public interface, so the newEventsmember is technically breaking for anyone who implements it outside Polecat. Same call Marten made withIBatchEvents, and the interface is store-owned in practice.Also fixed:
StreamState.AggregateTypewas never populatedNot in the issue, but the port surfaced it and it would have shipped a dead wire field.
pc_streams.typehas always been in the canonical projection and never read back, soStreamState.AggregateTypecame back null for every stream — which would have madeStreamStateResponse.AggregateTypeNamestructurally always-null on a brand new HTTP contract.The alias is written by the SQL Server dialect straight from
stream.AggregateType?.Name, and the JasperFx path that would register it for lookup (StreamAction.PrepareEvents→AggregateAliasFor) is one Polecat's QuickAppend closed-shape writer never goes through — soEventGraph.AggregateTypeForcould never resolve it. NewEventGraph.TryResolveAggregateTyperesolves against the registered projections first (the same strategyDocumentStore.EventStoreExplorer.ResolveAggregateTypealready used), caches the hit, and returns null instead of throwing for an alias this deployment does not know — a stream tagged by a deployment that knew a type this one does not must still report its version and timestamps.This fixes
FetchStreamStateAsyncfor every caller, not just the new endpoint. Covered both ways: a stream tagged with a registered aggregate resolves standalone and batched, an untagged stream reports null.Part 2 — the plans
FetchStreamStatePlanandFetchStreamPlanin thePolecatnamespace, both implementingIQueryPlan<T>andIBatchQueryPlan<T>, both withGuid streamId/string streamKeyconstructor overloads.FetchStreamStatePlanyields null for a missing stream;FetchStreamPlanyields an empty list.Part 3 —
Polecat.AspNetCoreStreamEventStateandStreamEvents, the event-side siblings ofStreamAggregate<T>, backed by the plans above. Both implementIResultandIEndpointMetadataProvider, both takeGuid/string/ pre-built-plan constructors, and both delegate the body write to newWriteStreamState/WriteEventsextensions onIQuerySession.Two things carried over verbatim from upstream:
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 outright — a result that serialized them naively fails at runtime for every user.StreamStateResponseandEventResponseproject them down: the aggregate type reduces to its simple name, andIEvent's assembly-qualifiedDotNetTypeNameis deliberately kept off the wire, leavingEventTypeNameas the client-side discriminator. Property names match Marten's DTOs so clients can move between the two stores unchanged.Empty streams are ambiguous.
FetchStreamyields an empty list both for a stream that does not exist and for a filter that excludes every event.StreamEvents.OnEmptyStatusdefaults to404to match the other single-resource results; set it to200to return an empty array, which is what you want paging forward withfromVersion.Serialization buffers through an
ArrayBufferWriter<byte>so the JSON never round-trips through a .NET string, andContent-Lengthis set on both responses.Tests
batch_event_fetching— the batched surface directly: state and stream fetches, missing-stream answers, each optional filter (including a dedicated timestamp case, the one parameter whose binding differs in kind from the rest), agreement with the standalone fetch row for row, and an interleaved batch of event fetches and a document load where a mis-ordered result set would surface as a cross-wired answer rather than a clean error.fetching_stream_query_plans— each plan standalone and batched, missing stream, version cap,fromVersion, both plans sharing one round trip with a document load, plus a separate string-identity store for thestreamKeyoverloads.stream_event_result_types_tests— 12 Alba tests over real Minimal API endpoints: both result types, the plan constructor, theOnEmptyStatusopt-out (including paging off the end of a stream that really exists),Content-Length, the serialized event body, the alias-not-dotnet-type wire contract, and OpenAPI metadata.Docs
Sections in
documents/querying/batched-queries.md(batched event fetches + event stream query plans),events/querying.md(stream fetches as query plans), anddocuments/aspnetcore.md(the two result types, the DTO rationale, and the empty-stream ambiguity).