Skip to content

feat(#370): FetchStreamStatePlan / FetchStreamPlan + batched event fetches + StreamEventState / StreamEvents (marten#5053 parity) - #372

Merged
jeremydmiller merged 1 commit into
mainfrom
fetch-stream-plans
Jul 26, 2026
Merged

feat(#370): FetchStreamStatePlan / FetchStreamPlan + batched event fetches + StreamEventState / StreamEvents (marten#5053 parity)#372
jeremydmiller merged 1 commit into
mainfrom
fetch-stream-plans

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

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 the Stream* 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.IBatchedQuery had QueryByPlan, Load/LoadMany/CheckExists, EventsExist and FetchForWritingByTags, but no Events surface at all — no batched counterpart to QueryEventStore.FetchStreamAsync / FetchStreamStateAsync.

  • New IBatchEvents, reached as batch.Events, with FetchStreamState and FetchStream in Guid and string overloads. FetchStream carries the same optional version / timestamp / fromVersion filters as the standalone fetch.
  • 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, 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, StreamIdentity specialization picked once rather than per row.
  • The batch now ensures the event store schema when it holds an event item (Is ApplyAllDatabaseChangesOnStartup required for Initial Baseline Data? #219 create-on-first-use). Flagged rather than ensured eagerly, so a document-only batch pays nothing for it.

IBatchedQuery is a public interface, so the new Events member is technically breaking for anyone who implements it outside Polecat. Same call Marten made with IBatchEvents, and the interface is store-owned in practice.

Also fixed: StreamState.AggregateType was never populated

Not in the issue, but the port surfaced it and it would have shipped a dead wire field. pc_streams.type has always been in the canonical projection and never read back, so StreamState.AggregateType came back null for every stream — which would have made StreamStateResponse.AggregateTypeName structurally 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.PrepareEventsAggregateAliasFor) 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 same strategy DocumentStore.EventStoreExplorer.ResolveAggregateType already 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 FetchStreamStateAsync for 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

FetchStreamStatePlan and FetchStreamPlan in the Polecat namespace, both implementing IQueryPlan<T> and IBatchQueryPlan<T>, both with Guid streamId / string streamKey constructor overloads. FetchStreamStatePlan yields null for a missing stream; FetchStreamPlan yields an empty list.

Part 3 — Polecat.AspNetCore

StreamEventState and StreamEvents, the event-side siblings of StreamAggregate<T>, backed by the plans above. Both implement IResult and IEndpointMetadataProvider, both take Guid / string / pre-built-plan constructors, and both delegate the body write to new WriteStreamState / WriteEvents extensions on IQuerySession.

Two things carried over verbatim from upstream:

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 outright — a result that serialized them naively fails at runtime for every user. StreamStateResponse and EventResponse project them down: 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. Property names match Marten's DTOs so clients can move between the two stores unchanged.

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. StreamEvents.OnEmptyStatus defaults to 404 to match the other single-resource results; set it to 200 to return an empty array, which is what you want paging forward with fromVersion.

Serialization buffers through an ArrayBufferWriter<byte> so the JSON never round-trips through a .NET string, and Content-Length is 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 the streamKey overloads.
  • stream_event_result_types_tests — 12 Alba tests over real Minimal API endpoints: both result types, the plan constructor, the OnEmptyStatus opt-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), and documents/aspnetcore.md (the two result types, the DTO rationale, and the empty-stream ambiguity).

…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
@jeremydmiller
jeremydmiller merged commit dc5f0da into main Jul 26, 2026
7 checks passed
@jeremydmiller
jeremydmiller deleted the fetch-stream-plans branch July 26, 2026 16:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Parity with marten#5053: FetchStreamStatePlan / FetchStreamPlan + StreamEventState / StreamEvents results

1 participant