Skip to content

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

Description

@jeremydmiller

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:

  1. add the batched event fetchers to IBatchedQuery (FetchStreamState and FetchStream, Guid and string overloads), then
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions