Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions docs/documents/aspnetcore.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,58 @@ Returns `200 application/json` with the latest projected aggregate state, or `40
stream exists. A constructor overload accepts `string` ids for stores configured with
string-keyed streams.

## Streaming Event Stream Metadata and Events

`StreamEventState` and `StreamEvents` are the event-side siblings of `StreamAggregate<T>`, backed by
the `FetchStreamStatePlan` / `FetchStreamPlan` query plans — so the same plan can be batched through
`IBatchedQuery.QueryByPlan()` and returned from an endpoint.

```csharp
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 take `Guid`, `string` and pre-built-plan constructors, and both implement `IResult` and
`IEndpointMetadataProvider` so OpenAPI advertises the right `200` and `404` shapes.

### StreamEventState vs StreamAggregate

- **`StreamEventState`** writes the stream's *metadata* — version, created/last timestamps, archived flag.
- **`StreamAggregate<T>`** writes the projected aggregate *state* built from the stream's events.

### The response DTOs

Neither result 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.
```

So the bodies are `StreamStateResponse` and `EventResponse`: the aggregate type reduces to its simple
name, and `IEvent`'s assembly-qualified `DotNetTypeName` is deliberately kept off the wire — use
`EventTypeName`, Polecat's stable event type alias, as the client-side discriminator. The property names
match Marten's equivalents, so a client 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, and the two cannot be told apart. `StreamEvents` exposes `OnEmptyStatus`, defaulting to `404`
to match the other single-resource results. Set it to `200` to return an empty array instead — which is
what you want when paging forward with `fromVersion` and running off the end is expected:

```csharp
app.MapGet("/orders/{id:guid}/events", (Guid id, long fromVersion, IQuerySession s) =>
new StreamEvents(s, id, fromVersion: fromVersion)
{
OnEmptyStatus = StatusCodes.Status200OK
});
```

Both responses set `Content-Length`, and serialization buffers through an `ArrayBufferWriter<byte>` so the
JSON never round-trips through a .NET string.

### StreamOne vs StreamAggregate

- **`StreamOne<T>`** is for regular documents — objects stored via `session.Store()` and
Expand Down
53 changes: 53 additions & 0 deletions docs/documents/querying/batched-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,56 @@ Query plans can also be used independently:
```cs
var orders = await session.QueryByPlanAsync(new ActiveOrdersPlan());
```

## Batched Event Store Fetches

`batch.Events` exposes the batched counterparts of `FetchStreamStateAsync` and `FetchStreamAsync`, so a
raw event-stream read can share the batch's single round trip with document loads and LINQ queries:

```cs
var batch = session.CreateBatchQuery();

var stateTask = batch.Events.FetchStreamState(streamId);
var eventsTask = batch.Events.FetchStream(streamId);
var orderTask = batch.Load<Order>(orderId);

await batch.Execute();

var state = await stateTask; // StreamState?, null when the stream does not exist
var events = await eventsTask; // IReadOnlyList<IEvent>, empty when the stream does not exist
var order = await orderTask;
```

Both come in `Guid` and `string` overloads for the two stream identity modes, and `FetchStream` carries the
same optional `version` / `timestamp` / `fromVersion` filters as `FetchStreamAsync`:

```cs
// Events up to and including version 5
var capped = batch.Events.FetchStream(streamId, version: 5);

// Everything appended from version 10 onward
var tail = batch.Events.FetchStream(streamId, fromVersion: 10);
```

## Event Stream Query Plans

`FetchStreamStatePlan` and `FetchStreamPlan` wrap those fetches as query plans. Both implement **both**
`IQueryPlan<T>` and `IBatchQueryPlan<T>`, so the same plan instance works standalone or in a batch:

```cs
// 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();
```

::: tip
Implementing both interfaces matters beyond convenience. Through Wolverine's fetch-specification feature,
a plan that implements only `IBatchQueryPlan<T>` produces uncompilable generated code — so a custom plan
you intend to route through a handler's `Load` should implement the pair as well.
:::
27 changes: 27 additions & 0 deletions docs/events/querying.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,33 @@ foreach (var @event in events)

Events are returned in version order. Archived streams are automatically excluded.

## Stream Fetches as Query Plans

`FetchStreamStatePlan` and `FetchStreamPlan` wrap the two raw stream fetches as reusable query plans.
Both implement `IQueryPlan<T>` **and** `IBatchQueryPlan<T>`, so the same plan works standalone or inside
a batched query, and both offer `Guid streamId` / `string streamKey` constructor overloads:

```cs
// 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();
```

`FetchStreamStatePlan` yields `null` when the stream does not exist; `FetchStreamPlan` yields an empty
list and carries `FetchStream`'s optional `version` / `timestamp` / `fromVersion` arguments.

The underlying batched fetchers are also available directly as `batch.Events.FetchStreamState(...)` and
`batch.Events.FetchStream(...)` — see [Batched Queries](/documents/querying/batched-queries#batched-event-store-fetches).

To return either straight from an ASP.NET Core endpoint, see the `StreamEventState` and `StreamEvents`
result types in [ASP.NET Core Integration](/documents/aspnetcore#streaming-event-stream-metadata-and-events).

## AggregateStreamAsync

Replay events to build the current aggregate state:
Expand Down
23 changes: 23 additions & 0 deletions src/Polecat.AspNetCore.Testing/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,29 @@
new StreamPagedByCursor<StreamingIssue>(
session.Query<StreamingIssue>().OrderBy(x => x.Number).ThenBy(x => x.Id), cursor, pageSize));

// #370 StreamEventState endpoint — stream metadata (version/timestamps/archived) or 404
app.MapGet("/api/streams/{id:guid}/state", (Guid id, IQuerySession session) =>
new StreamEventState(session, id));

// #370 StreamEvents endpoint — the stream's raw events as a JSON array, or 404 when empty
app.MapGet("/api/streams/{id:guid}/events", (Guid id, IQuerySession session) =>
new StreamEvents(session, id));

// OnEmptyStatus opt-out — an empty stream answers 200 with an empty array rather than 404, which is
// what a caller paging forward with fromVersion wants when it runs off the end.
app.MapGet("/api/streams/{id:guid}/events-empty200", (Guid id, long? fromVersion, IQuerySession session) =>
new StreamEvents(session, id, fromVersion: fromVersion ?? 0)
{
OnEmptyStatus = StatusCodes.Status200OK
});

// Pre-built plan constructor — a handler can build the plan once and either batch it or return it
app.MapGet("/api/streams/{id:guid}/events-by-plan", (Guid id, IQuerySession session) =>
new StreamEvents(session, new FetchStreamPlan(id, version: 1)));

app.MapGet("/api/streams/{id:guid}/state-by-plan", (Guid id, IQuerySession session) =>
new StreamEventState(session, new FetchStreamStatePlan(id)));

app.Run();

namespace Polecat.AspNetCore.Testing
Expand Down
Loading
Loading