@cratis/pi 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -0
- package/package.json +24 -0
- package/skills/cratis-application-react-specifications/LICENSE +2 -0
- package/skills/cratis-application-react-specifications/SKILL.md +192 -0
- package/skills/cratis-application-slice-specifications/LICENSE +2 -0
- package/skills/cratis-application-slice-specifications/SKILL.md +222 -0
- package/skills/cratis-arc-authentication-authorization-and-identity/LICENSE +2 -0
- package/skills/cratis-arc-authentication-authorization-and-identity/SKILL.md +233 -0
- package/skills/cratis-arc-authentication-authorization-and-identity/references/authentication.md +140 -0
- package/skills/cratis-arc-authentication-authorization-and-identity/references/authorization.md +112 -0
- package/skills/cratis-arc-authentication-authorization-and-identity/references/frontend.md +102 -0
- package/skills/cratis-arc-authentication-authorization-and-identity/references/local-development.md +85 -0
- package/skills/cratis-arc-authentication-authorization-and-identity/references/tenancy.md +87 -0
- package/skills/cratis-arc-command/LICENSE +2 -0
- package/skills/cratis-arc-command/SKILL.md +286 -0
- package/skills/cratis-arc-command/references/command-result.md +111 -0
- package/skills/cratis-arc-command/references/handler-shapes.md +135 -0
- package/skills/cratis-arc-command/references/proxy-generation.md +87 -0
- package/skills/cratis-arc-command/references/read-model-injection.md +97 -0
- package/skills/cratis-arc-command-execution/LICENSE +2 -0
- package/skills/cratis-arc-command-execution/SKILL.md +148 -0
- package/skills/cratis-arc-command-validation/LICENSE +2 -0
- package/skills/cratis-arc-command-validation/SKILL.md +230 -0
- package/skills/cratis-arc-ef-core-migration/LICENSE +2 -0
- package/skills/cratis-arc-ef-core-migration/SKILL.md +231 -0
- package/skills/cratis-arc-observable-query-http/LICENSE +2 -0
- package/skills/cratis-arc-observable-query-http/SKILL.md +157 -0
- package/skills/cratis-arc-query-paging/LICENSE +2 -0
- package/skills/cratis-arc-query-paging/SKILL.md +173 -0
- package/skills/cratis-arc-react-page/LICENSE +2 -0
- package/skills/cratis-arc-react-page/SKILL.md +291 -0
- package/skills/cratis-arc-react-page/references/data-page.md +125 -0
- package/skills/cratis-arc-react-page/references/data-tables.md +136 -0
- package/skills/cratis-arc-react-page/references/dialogs.md +238 -0
- package/skills/cratis-arc-react-page/references/mvvm.md +150 -0
- package/skills/cratis-arc-react-page/references/queries-and-commands.md +200 -0
- package/skills/cratis-chronicle-cli-operations/LICENSE +2 -0
- package/skills/cratis-chronicle-cli-operations/SKILL.md +199 -0
- package/skills/cratis-chronicle-client-dotnet/LICENSE +2 -0
- package/skills/cratis-chronicle-client-dotnet/SKILL.md +433 -0
- package/skills/cratis-chronicle-client-elixir/LICENSE +2 -0
- package/skills/cratis-chronicle-client-elixir/SKILL.md +429 -0
- package/skills/cratis-chronicle-client-kotlin/LICENSE +2 -0
- package/skills/cratis-chronicle-client-kotlin/SKILL.md +498 -0
- package/skills/cratis-chronicle-client-typescript/LICENSE +2 -0
- package/skills/cratis-chronicle-client-typescript/SKILL.md +396 -0
- package/skills/cratis-chronicle-compliance/LICENSE +2 -0
- package/skills/cratis-chronicle-compliance/SKILL.md +246 -0
- package/skills/cratis-chronicle-event-constraints/LICENSE +2 -0
- package/skills/cratis-chronicle-event-constraints/SKILL.md +227 -0
- package/skills/cratis-chronicle-event-modeling/LICENSE +2 -0
- package/skills/cratis-chronicle-event-modeling/SKILL.md +157 -0
- package/skills/cratis-chronicle-event-specifications/LICENSE +2 -0
- package/skills/cratis-chronicle-event-specifications/SKILL.md +200 -0
- package/skills/cratis-chronicle-event-type-migration/LICENSE +2 -0
- package/skills/cratis-chronicle-event-type-migration/SKILL.md +235 -0
- package/skills/cratis-chronicle-mcp-inspection/LICENSE +2 -0
- package/skills/cratis-chronicle-mcp-inspection/SKILL.md +79 -0
- package/skills/cratis-chronicle-mcp-inspection/references/blocked-tools.md +12 -0
- package/skills/cratis-chronicle-mcp-inspection/references/observational-tools.md +8 -0
- package/skills/cratis-chronicle-multi-tenancy/LICENSE +2 -0
- package/skills/cratis-chronicle-multi-tenancy/SKILL.md +198 -0
- package/skills/cratis-chronicle-projection/LICENSE +2 -0
- package/skills/cratis-chronicle-projection/SKILL.md +162 -0
- package/skills/cratis-chronicle-projection/references/fluent-builder.md +126 -0
- package/skills/cratis-chronicle-projection/references/model-bound-attributes.md +118 -0
- package/skills/cratis-chronicle-reactor/LICENSE +2 -0
- package/skills/cratis-chronicle-reactor/SKILL.md +344 -0
- package/skills/cratis-chronicle-read-model/LICENSE +2 -0
- package/skills/cratis-chronicle-read-model/SKILL.md +193 -0
- package/skills/cratis-chronicle-read-model/references/queries.md +142 -0
- package/skills/cratis-chronicle-read-model-specifications/LICENSE +2 -0
- package/skills/cratis-chronicle-read-model-specifications/SKILL.md +176 -0
- package/skills/cratis-chronicle-reducer/LICENSE +2 -0
- package/skills/cratis-chronicle-reducer/SKILL.md +173 -0
- package/skills/cratis-chronicle-web-workbench/LICENSE +2 -0
- package/skills/cratis-chronicle-web-workbench/SKILL.md +177 -0
- package/skills/cratis-cli-terminal-workbench/LICENSE +2 -0
- package/skills/cratis-cli-terminal-workbench/SKILL.md +156 -0
- package/skills/cratis-code-review/LICENSE +2 -0
- package/skills/cratis-code-review/SKILL.md +177 -0
- package/skills/cratis-components-accessibility/LICENSE +2 -0
- package/skills/cratis-components-accessibility/SKILL.md +176 -0
- package/skills/cratis-components-schema-editor/LICENSE +2 -0
- package/skills/cratis-components-schema-editor/SKILL.md +238 -0
- package/skills/cratis-components-stepper-command-dialog/LICENSE +2 -0
- package/skills/cratis-components-stepper-command-dialog/SKILL.md +209 -0
- package/skills/cratis-components-styling/LICENSE +2 -0
- package/skills/cratis-components-styling/SKILL.md +221 -0
- package/skills/cratis-components-toolbar/LICENSE +2 -0
- package/skills/cratis-components-toolbar/SKILL.md +259 -0
- package/skills/cratis-documentation-writing/LICENSE +2 -0
- package/skills/cratis-documentation-writing/SKILL.md +121 -0
- package/skills/cratis-event-model-diagram/LICENSE +2 -0
- package/skills/cratis-event-model-diagram/SKILL.md +142 -0
- package/skills/cratis-fundamentals-concept/LICENSE +2 -0
- package/skills/cratis-fundamentals-concept/SKILL.md +198 -0
- package/skills/cratis-fundamentals-type-discovery/LICENSE +2 -0
- package/skills/cratis-fundamentals-type-discovery/SKILL.md +152 -0
- package/skills/cratis-governed-release-methodology/LICENSE +2 -0
- package/skills/cratis-governed-release-methodology/SKILL.md +297 -0
- package/skills/cratis-lens-browser-extension/LICENSE +2 -0
- package/skills/cratis-lens-browser-extension/SKILL.md +239 -0
- package/skills/cratis-performance-review/LICENSE +2 -0
- package/skills/cratis-performance-review/SKILL.md +139 -0
- package/skills/cratis-screenplay-model-authoring/LICENSE +2 -0
- package/skills/cratis-screenplay-model-authoring/SKILL.md +231 -0
- package/skills/cratis-security-review/LICENSE +2 -0
- package/skills/cratis-security-review/SKILL.md +141 -0
- package/skills/cratis-specification-by-example/LICENSE +2 -0
- package/skills/cratis-specification-by-example/SKILL.md +188 -0
- package/skills/cratis-specifications-csharp/LICENSE +2 -0
- package/skills/cratis-specifications-csharp/SKILL.md +230 -0
- package/skills/cratis-specifications-csharp/references/application-scenarios.md +191 -0
- package/skills/cratis-specifications-csharp/references/csharp-patterns.md +142 -0
- package/skills/cratis-specifications-csharp/references/integration-specs.md +126 -0
- package/skills/cratis-specifications-typescript/LICENSE +2 -0
- package/skills/cratis-specifications-typescript/SKILL.md +202 -0
- package/skills/cratis-specifications-typescript/references/typescript-patterns.md +168 -0
- package/skills/cratis-stage-rendering-and-sandbox/LICENSE +2 -0
- package/skills/cratis-stage-rendering-and-sandbox/SKILL.md +230 -0
- package/skills/cratis-studio-mcp-safety-guidance/LICENSE +2 -0
- package/skills/cratis-studio-mcp-safety-guidance/SKILL.md +69 -0
- package/skills/cratis-studio-mcp-safety-guidance/references/blocked-tools.md +12 -0
- package/skills/cratis-studio-mcp-safety-guidance/references/observational-tools.md +8 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cratis-chronicle-reactor
|
|
3
|
+
description: Implement a Chronicle IReactor - an automation that causes an external side effect, or a translation that appends follow-up events. Covers handler dispatch and parameter resolution, every supported return type, targeting another event source, replay handling with [OnceOnly] and [Replay], event filtering, failure and quarantine behavior, and the analyzer rules. Use for event-driven side effects; do not use to populate a read model.
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Chronicle reactors
|
|
8
|
+
|
|
9
|
+
A reactor observes events and **does things**: calls an external system, or
|
|
10
|
+
appends follow-up events. A projection or reducer builds state; a reactor causes
|
|
11
|
+
effects. If the answer is "populate a queryable model", it is not a reactor.
|
|
12
|
+
|
|
13
|
+
| Need | Use |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| populate a queryable read model from events | a projection |
|
|
16
|
+
| a current-state-plus-event transition | a reducer |
|
|
17
|
+
| trigger a side effect outside the system | a reactor (**automation**) |
|
|
18
|
+
| append follow-up events elsewhere in the system | a reactor (**translation**) |
|
|
19
|
+
| both a model and an effect | one projection *and* one reactor |
|
|
20
|
+
|
|
21
|
+
## Verified product sources
|
|
22
|
+
|
|
23
|
+
This skill is verified against these exact sources:
|
|
24
|
+
|
|
25
|
+
| Package | Version | Purpose |
|
|
26
|
+
| --- | --- | --- |
|
|
27
|
+
| `Cratis.Chronicle` | `16.45.2` | `Cratis.Chronicle.Reactors`, `Cratis.Chronicle.Reactors.SideEffects`, `Cratis.Chronicle.EventSequences`, `Cratis.Chronicle.Events` |
|
|
28
|
+
| `Cratis.Chronicle.CodeAnalysis` | `16.45.2` | `CHR0004`, `CHR0005`, `CHR0008`, `CHR0013`, `CHR0022`, `CHR0031`, `CHR0032` |
|
|
29
|
+
|
|
30
|
+
Reverify product sources before claiming support for another version.
|
|
31
|
+
|
|
32
|
+
> **There is no `ReactorSideEffect` type.** It does not exist in Chronicle, in
|
|
33
|
+
> any version. The real return types are listed below; the per-event metadata
|
|
34
|
+
> control it was imagined to provide is what `EventForEventSourceId` actually
|
|
35
|
+
> gives you. Never write it into code or an example.
|
|
36
|
+
|
|
37
|
+
## Write the reactor
|
|
38
|
+
|
|
39
|
+
```csharp
|
|
40
|
+
using Cratis.Chronicle.Events;
|
|
41
|
+
using Cratis.Chronicle.Reactors;
|
|
42
|
+
|
|
43
|
+
/// <summary>
|
|
44
|
+
/// <what this automation does and why>.
|
|
45
|
+
/// </summary>
|
|
46
|
+
public class <ReactorName>(<IServiceType> <serviceName>) : IReactor
|
|
47
|
+
{
|
|
48
|
+
/// <summary>Reacts to <see cref="<EventName>"/>.</summary>
|
|
49
|
+
public async Task <MethodName>(<EventName> @event, EventContext context) =>
|
|
50
|
+
await <serviceName>.<Operation>(@event.<Property>);
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`Cratis.Chronicle.Reactors.IReactor` is a **marker interface with no members**.
|
|
55
|
+
Reactors are discovered by that interface — the `[Reactor]` attribute is optional
|
|
56
|
+
and is not what makes a class a reactor. Generic types are not discovered.
|
|
57
|
+
|
|
58
|
+
## Dispatch and parameters
|
|
59
|
+
|
|
60
|
+
**Dispatch is by the first parameter's type.** The method name is for the reader
|
|
61
|
+
and plays no part. When two methods claim the same event type, the winner is
|
|
62
|
+
chosen by public before non-public, then more parameters before fewer, then
|
|
63
|
+
ordinal name order — so do not rely on it; write one handler per event type.
|
|
64
|
+
|
|
65
|
+
A handler takes **at least one** parameter. There is no upper limit. Everything
|
|
66
|
+
after the first is resolved by type, position-independently:
|
|
67
|
+
|
|
68
|
+
| Parameter type | Resolved as |
|
|
69
|
+
| --- | --- |
|
|
70
|
+
| `EventContext` | the event context |
|
|
71
|
+
| `ReactorDelivery` | reactor id, event store, namespace, event sequence, partition, sequence number |
|
|
72
|
+
| a type that has a projection or a reducer | that **read model**, materialized for this event |
|
|
73
|
+
| anything else | from the service provider |
|
|
74
|
+
|
|
75
|
+
An unresolvable parameter throws at invocation, and analyzer **CHR0004**
|
|
76
|
+
(warning) flags it at build time: after the event, a reactor method may take the
|
|
77
|
+
event context, a read model, or a service — not a primitive, a value type, or a
|
|
78
|
+
string.
|
|
79
|
+
|
|
80
|
+
The first parameter's type must carry `[EventType]`, or analyzer **CHR0005**
|
|
81
|
+
(error) rejects it.
|
|
82
|
+
|
|
83
|
+
### Injecting a read model
|
|
84
|
+
|
|
85
|
+
A read-model parameter is materialized using the event context's event-source id
|
|
86
|
+
by default. When the model's key differs from the triggering event's source —
|
|
87
|
+
the event carries a related entity's id — implement `ICanResolveReadModelKey` on
|
|
88
|
+
the reactor:
|
|
89
|
+
|
|
90
|
+
```csharp
|
|
91
|
+
public class <ReactorName> : IReactor, ICanResolveReadModelKey
|
|
92
|
+
{
|
|
93
|
+
public ReadModelKey Resolve(object @event, EventContext context) =>
|
|
94
|
+
((<EventName>)@event).<RelatedIdProperty>;
|
|
95
|
+
|
|
96
|
+
public Task <MethodName>(<EventName> @event, <ReadModelName> <readModel>) =>
|
|
97
|
+
Task.CompletedTask;
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The resolved key applies to every read-model parameter across all of the
|
|
102
|
+
reactor's handlers.
|
|
103
|
+
|
|
104
|
+
A **materialized** read model is read from its sink, so it is eventually
|
|
105
|
+
consistent — as current as its own observer. Mark the read model `[Passive]` when
|
|
106
|
+
the reactor needs it strongly consistent: a passive model has no sink and is
|
|
107
|
+
computed on demand at the point the reactor runs.
|
|
108
|
+
|
|
109
|
+
Prefer the event's own data. Analyzer **CHR0032** (warning) rejects injecting a
|
|
110
|
+
storage primitive such as `IMongoCollection<T>` directly — read keyed state
|
|
111
|
+
through a read-model parameter or `IReadModels.GetInstanceById` instead.
|
|
112
|
+
|
|
113
|
+
## Return types
|
|
114
|
+
|
|
115
|
+
| Return | Effect |
|
|
116
|
+
| --- | --- |
|
|
117
|
+
| `void`, `Task` | no side-effect append |
|
|
118
|
+
| `TEvent`, `Task<TEvent>` | append one event, with reactor-level metadata |
|
|
119
|
+
| `IEnumerable<object>`, `Task<IEnumerable<object>>` | append several, in one transaction |
|
|
120
|
+
| `EventForEventSourceId`, `Task<...>` | append one event to an explicit event source |
|
|
121
|
+
| `IEnumerable<EventForEventSourceId>`, `Task<...>` | append to several event sources, in one transaction |
|
|
122
|
+
| `EventsWithConcurrencyScopes`, `Task<...>` | append with explicit concurrency scopes |
|
|
123
|
+
|
|
124
|
+
A collection may **mix** bare events and `EventForEventSourceId` items; each is
|
|
125
|
+
appended with its own metadata, all in one transaction.
|
|
126
|
+
|
|
127
|
+
A custom side-effect type handled by your own `IReactorSideEffectHandler` is
|
|
128
|
+
only recognised when returned as `Task<T>`.
|
|
129
|
+
|
|
130
|
+
### Returning events instead of injecting `IEventLog`
|
|
131
|
+
|
|
132
|
+
Return the events. Do not inject `Cratis.Chronicle.EventSequences.IEventLog` into
|
|
133
|
+
a reactor. **This is a convention, not a framework contract** — no analyzer
|
|
134
|
+
enforces it — but returning the events keeps the reactor free of the event store
|
|
135
|
+
and keeps the append inside Chronicle's own transaction.
|
|
136
|
+
|
|
137
|
+
```csharp
|
|
138
|
+
// One event, on the triggering event's own event source.
|
|
139
|
+
public Task<<ResultEvent>> <MethodName>(<EventName> @event, EventContext context) =>
|
|
140
|
+
Task.FromResult(new <ResultEvent>(@event.<Property>));
|
|
141
|
+
|
|
142
|
+
// Several events on that same event source.
|
|
143
|
+
public Task<IEnumerable<object>> <MethodName>(<EventName> @event, EventContext context) =>
|
|
144
|
+
Task.FromResult<IEnumerable<object>>([new <ResultEvent>(), new <OtherEvent>()]);
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Targeting another event source
|
|
148
|
+
|
|
149
|
+
`Cratis.Chronicle.EventSequences.EventForEventSourceId` is the cross-stream
|
|
150
|
+
wrapper:
|
|
151
|
+
|
|
152
|
+
```csharp
|
|
153
|
+
public record EventForEventSourceId(EventSourceId EventSourceId, object Event, Causation? Causation = default)
|
|
154
|
+
{
|
|
155
|
+
public Subject? Subject { get; init; }
|
|
156
|
+
public EventStreamType EventStreamType { get; init; }
|
|
157
|
+
public EventStreamId EventStreamId { get; init; }
|
|
158
|
+
public EventSourceType EventSourceType { get; init; }
|
|
159
|
+
public DateTimeOffset? Occurred { get; init; }
|
|
160
|
+
public IEnumerable<string> Tags { get; init; }
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
```csharp
|
|
165
|
+
public Task<IEnumerable<EventForEventSourceId>> <MethodName>(<EventName> @event, EventContext context) =>
|
|
166
|
+
Task.FromResult<IEnumerable<EventForEventSourceId>>(
|
|
167
|
+
[
|
|
168
|
+
new(@event.<RelatedId>, new <ResultEvent>(@event.<Property>)),
|
|
169
|
+
new(@event.<OtherId>, new <OtherEvent>()) { EventStreamType = new("<stream-type>") },
|
|
170
|
+
]);
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
It does **not** carry an event sequence id; side effects are appended to the
|
|
174
|
+
event log.
|
|
175
|
+
|
|
176
|
+
### Where the metadata for a bare event comes from
|
|
177
|
+
|
|
178
|
+
For a **bare** event return, Chronicle resolves the append metadata from the
|
|
179
|
+
reactor itself: the `[EventSourceType]`, `[EventStreamType]`, and
|
|
180
|
+
`[EventStreamId]` attributes, and the `ICanProvideEventSourceId`,
|
|
181
|
+
`ICanProvideEventStreamId`, and `ICanProvideSubject` interfaces (all in
|
|
182
|
+
`Cratis.Chronicle.Events`, each with one method).
|
|
183
|
+
|
|
184
|
+
An `EventForEventSourceId` is **self-describing** — reactor-level metadata is not
|
|
185
|
+
applied to it. In a mixed collection, bare items get the reactor metadata and
|
|
186
|
+
wrapper items keep their own.
|
|
187
|
+
|
|
188
|
+
## Replay
|
|
189
|
+
|
|
190
|
+
A reactor sees the same event twice for different reasons: when it happens, and
|
|
191
|
+
again when its observer is replayed.
|
|
192
|
+
|
|
193
|
+
`Cratis.Chronicle.Reactors.OnceOnlyAttribute` (`[OnceOnly]`) targets a **class or
|
|
194
|
+
a method** and means **replay exclusion**:
|
|
195
|
+
|
|
196
|
+
- on the class, the observer is marked not replayable and the replay never
|
|
197
|
+
starts;
|
|
198
|
+
- on a method, that handler is skipped while the delivery is a replay.
|
|
199
|
+
|
|
200
|
+
**`[OnceOnly]` is not per-event-source deduplication.** There is no ledger of
|
|
201
|
+
which events a handler has already seen. Recovering a failed partition
|
|
202
|
+
re-delivers the event as an ordinary observation, and a `[OnceOnly]` handler runs
|
|
203
|
+
again. Design the side effect to be idempotent regardless — an external
|
|
204
|
+
idempotency key, an upsert, a conditional write.
|
|
205
|
+
|
|
206
|
+
Analyzer **CHR0022** (warning) requires `[OnceOnly]` on a handler that returns
|
|
207
|
+
event side effects, because a replay would otherwise append duplicates.
|
|
208
|
+
|
|
209
|
+
`Cratis.Chronicle.Reactors.ReplayAttribute` (`[Replay]`) is the other half, and
|
|
210
|
+
targets a method only. Mark a **second** handler for the same event type with it
|
|
211
|
+
when replay needs to do something *different* rather than nothing:
|
|
212
|
+
|
|
213
|
+
```csharp
|
|
214
|
+
public Task <MethodName>(<EventName> @event) => <serviceName>.<Operation>(@event.<Property>);
|
|
215
|
+
|
|
216
|
+
[Replay]
|
|
217
|
+
public Task <MethodName>DuringReplay(<EventName> @event) => Task.CompletedTask;
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
With a `[Replay]` handler present, only it runs during replay. Without one, the
|
|
221
|
+
ordinary handler runs during replay as before. An event type handled *only* by a
|
|
222
|
+
`[Replay]` handler is still subscribed to.
|
|
223
|
+
|
|
224
|
+
## Identity and event sequence
|
|
225
|
+
|
|
226
|
+
`Cratis.Chronicle.Reactors.ReactorAttribute` takes two optional arguments:
|
|
227
|
+
|
|
228
|
+
```csharp
|
|
229
|
+
public sealed class ReactorAttribute(string id = "", string? eventSequence = default) : Attribute
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
**There is no `isActive` argument** — that belongs to `[Reducer]`. Without an
|
|
233
|
+
explicit `id`, the reactor's identity is its full type name.
|
|
234
|
+
|
|
235
|
+
The source sequence resolves in this order: an `[EventStore]` attribute (which
|
|
236
|
+
selects the inbox for that store), then `[EventSequence]`/`[EventLog]`, then
|
|
237
|
+
`Reactor(eventSequence: ...)`, then the event store inferred from the handled
|
|
238
|
+
event types, then the event log.
|
|
239
|
+
|
|
240
|
+
**`[EventStore]` cannot be combined** with `[EventSequence]`, `[EventLog]`, or
|
|
241
|
+
`Reactor(eventSequence: ...)` — analyzer **CHR0013** (error), and a runtime
|
|
242
|
+
exception. All of a reactor's event types must come from one event store —
|
|
243
|
+
analyzer **CHR0008** (error).
|
|
244
|
+
|
|
245
|
+
### Cross-service events
|
|
246
|
+
|
|
247
|
+
Cross-service facts route through the outbox and inbox rather than a shared log.
|
|
248
|
+
The producer appends its public contract event to the outbox sequence; the
|
|
249
|
+
consuming observer listens on the implicit inbox sequence for the source store.
|
|
250
|
+
Chronicle creates or reuses that subscription from the observer's `[EventStore]`
|
|
251
|
+
metadata, or from `[EventStore]` on the event type or its assembly. Put
|
|
252
|
+
`[assembly: EventStore("<source-store>")]` in a contracts project when every
|
|
253
|
+
event there originates from one service.
|
|
254
|
+
|
|
255
|
+
## Filtering which events reach the reactor
|
|
256
|
+
|
|
257
|
+
| Attribute | Effect |
|
|
258
|
+
| --- | --- |
|
|
259
|
+
| `[FilterEventsByTag("<tag>")]` | **filters** by appended tag; repeatable, and any one match admits the event |
|
|
260
|
+
| `[EventSourceType("<value>")]` | **filters** by the appended event source type |
|
|
261
|
+
| `[EventStreamType("<value>")]` | **filters** by the appended event stream type |
|
|
262
|
+
| `[EventStreamId("<value>")]` | **does not filter**; it is append metadata for side effects |
|
|
263
|
+
| `[Tag]`, `[Tags]` | **label** the reactor; they never filter |
|
|
264
|
+
|
|
265
|
+
Filter categories combine with AND; tags within the tag category combine with OR.
|
|
266
|
+
These correspond to metadata supplied at append time:
|
|
267
|
+
|
|
268
|
+
```csharp
|
|
269
|
+
await eventStore.EventLog.Append(
|
|
270
|
+
<eventSourceId>,
|
|
271
|
+
new <EventName>(<arguments>),
|
|
272
|
+
eventStreamType: "<stream-type>",
|
|
273
|
+
eventSourceType: "<source-type>",
|
|
274
|
+
tags: ["<tag>"]);
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
A **projection cannot be filtered at all** — if you need metadata-based
|
|
278
|
+
selection, a reactor or a reducer is where it happens.
|
|
279
|
+
|
|
280
|
+
## Translation via a command
|
|
281
|
+
|
|
282
|
+
A translation that adapts one area's events into another's intent runs a command
|
|
283
|
+
rather than appending directly. `ICommandPipeline` is an **Arc** type
|
|
284
|
+
(`Cratis.Arc.Commands`), not a Chronicle one:
|
|
285
|
+
|
|
286
|
+
```csharp
|
|
287
|
+
using Cratis.Arc.Commands;
|
|
288
|
+
|
|
289
|
+
public class <ReactorName>(ICommandPipeline commandPipeline) : IReactor
|
|
290
|
+
{
|
|
291
|
+
public async Task <MethodName>(<EventName> @event, EventContext context) =>
|
|
292
|
+
await commandPipeline.Execute(new <CommandName>(@event.<Property>));
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
## Failure behavior
|
|
297
|
+
|
|
298
|
+
If a handler throws, or a returned side-effect event fails to append — a
|
|
299
|
+
constraint violation, a concurrency violation, or an error — the failing
|
|
300
|
+
event-source partition **pauses** until the cause is resolved. Repeated failures
|
|
301
|
+
can **quarantine** the observer, which stops retries and suppresses automatic
|
|
302
|
+
recovery. **A quarantined observer does not resume on reconnect**; an operator
|
|
303
|
+
must clear the quarantine explicitly.
|
|
304
|
+
|
|
305
|
+
Do not throw to reject a malformed inbound event. A reactor is not a data-quality
|
|
306
|
+
gate; invalid payloads belong at the command or append site. When a malformed
|
|
307
|
+
cross-service fact arrives, append a clear failure or dead-letter event, or
|
|
308
|
+
surface it through the operational failure path, and skip the partial side
|
|
309
|
+
effect. Throwing just to reject it pauses the partition and can quarantine the
|
|
310
|
+
whole observer.
|
|
311
|
+
|
|
312
|
+
## Keep the reactor stateless
|
|
313
|
+
|
|
314
|
+
Inject collaborators through the primary constructor; hold no mutable state on
|
|
315
|
+
the class. Analyzer **CHR0031** (warning) flags mutable reactor state. Keep each
|
|
316
|
+
reactor focused on one automation concern; several handlers in one class are fine
|
|
317
|
+
when they serve that concern.
|
|
318
|
+
|
|
319
|
+
## Specifications
|
|
320
|
+
|
|
321
|
+
Drive events through the reactor scenario helper with a service provider of
|
|
322
|
+
substitutes, and assert on those substitutes for non-event side effects. For
|
|
323
|
+
handlers that return events, assert the resulting appends through the scenario's
|
|
324
|
+
event store. Cover the replay path separately when the reactor has `[OnceOnly]`
|
|
325
|
+
or `[Replay]` handlers.
|
|
326
|
+
|
|
327
|
+
## Verify
|
|
328
|
+
|
|
329
|
+
- The class implements `IReactor` and is not generic.
|
|
330
|
+
- Every handler's first parameter is an `[EventType]` record, and there is one
|
|
331
|
+
handler per event type.
|
|
332
|
+
- Every additional parameter resolves to the event context, `ReactorDelivery`, a
|
|
333
|
+
read model, or a service.
|
|
334
|
+
- No handler returns or references `ReactorSideEffect`.
|
|
335
|
+
- No `IEventLog` is injected; side-effect events are returned instead.
|
|
336
|
+
- Handlers returning events carry `[OnceOnly]`.
|
|
337
|
+
- Side effects are idempotent even with `[OnceOnly]`, because recovery
|
|
338
|
+
re-delivers.
|
|
339
|
+
- `[EventStore]` is not combined with an explicit event sequence.
|
|
340
|
+
- No filter attribute is placed where it is inert.
|
|
341
|
+
- The reactor holds no mutable state and injects no storage primitive.
|
|
342
|
+
- The build is clean with no `CHR0004`, `CHR0005`, `CHR0008`, `CHR0013`,
|
|
343
|
+
`CHR0022`, `CHR0031`, or `CHR0032` outstanding, and the reactor
|
|
344
|
+
specifications pass.
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cratis-chronicle-read-model
|
|
3
|
+
description: Create a Chronicle read model and its query surface - past-tense event types, the read-model record and its key, choosing a projection or a reducer to build it, snapshot versus observable queries, and reading an instance directly through IReadModels. Use when creating a read model from scratch; for changing how an existing model is populated use the projection or reducer skill instead.
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Chronicle read models
|
|
8
|
+
|
|
9
|
+
A read model is derived state built from events. The path is:
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
event types -> read-model record -> a projection or a reducer -> a query surface
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Chronicle owns the events, the projection or reducer, and the stored instance.
|
|
16
|
+
The **query surface is Arc's**, and the two halves are separable: a read model
|
|
17
|
+
that only exists to be read from inside the backend needs no query surface at
|
|
18
|
+
all.
|
|
19
|
+
|
|
20
|
+
## Verified product sources
|
|
21
|
+
|
|
22
|
+
This skill is verified against these exact sources:
|
|
23
|
+
|
|
24
|
+
| Package | Version | Purpose |
|
|
25
|
+
| --- | --- | --- |
|
|
26
|
+
| `Cratis.Chronicle` | `16.45.2` | `Cratis.Chronicle.Events`, `Cratis.Chronicle.Keys`, `Cratis.Chronicle.ReadModels` |
|
|
27
|
+
| `Cratis.Arc.Core` | current | `Cratis.Arc.Queries.ModelBound.ReadModelAttribute` and `PathAttribute` |
|
|
28
|
+
| `Cratis.Arc.MongoDB` | current | the `Observe` family of `IMongoCollection<T>` extensions |
|
|
29
|
+
|
|
30
|
+
Reverify product sources before claiming support for another version. Confirm
|
|
31
|
+
the exact Arc package version in the consuming project before citing one.
|
|
32
|
+
|
|
33
|
+
## Step 1 — Define the events
|
|
34
|
+
|
|
35
|
+
Events are the source of truth. Each is a `record` carrying
|
|
36
|
+
`Cratis.Chronicle.Events.EventTypeAttribute`, named as a past-tense fact.
|
|
37
|
+
|
|
38
|
+
```csharp
|
|
39
|
+
using Cratis.Chronicle.Events;
|
|
40
|
+
|
|
41
|
+
/// <summary>Emitted when <description>.</summary>
|
|
42
|
+
[EventType]
|
|
43
|
+
public record <EntityName><PastTenseVerb>(<ConceptType> <PropertyName>);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
- One clear purpose per event; do not mix concerns into one fact.
|
|
47
|
+
- **Avoid nullable properties.** Analyzer `CHR0012` warns on them. Model an
|
|
48
|
+
optional fact as a separate event.
|
|
49
|
+
- Prefer strongly typed concept properties over raw primitives.
|
|
50
|
+
- An event never carries its own event-source id as a payload property — the id
|
|
51
|
+
is in the event context.
|
|
52
|
+
- `[EventType]` takes `(string id = "", uint generation = 1)`. Pass neither for a
|
|
53
|
+
new event: the id defaults to the CLR type name and the generation to `1`.
|
|
54
|
+
|
|
55
|
+
## Step 2 — Define the read-model record
|
|
56
|
+
|
|
57
|
+
```csharp
|
|
58
|
+
using Cratis.Chronicle.Keys;
|
|
59
|
+
|
|
60
|
+
public record <ReadModelName>(
|
|
61
|
+
[property: Key] <IdType> Id,
|
|
62
|
+
<PropertyType> <PropertyName>);
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- `Cratis.Chronicle.Keys.KeyAttribute` takes no arguments and targets a property
|
|
66
|
+
or a record constructor parameter, never the class.
|
|
67
|
+
- When the model's identity **is** the event-source id, that is already the key
|
|
68
|
+
and `[Key]` adds nothing. Placing `[Key]` on an `EventSourceId<T>`-derived
|
|
69
|
+
value is flagged by analyzer `CHR0026`.
|
|
70
|
+
- One read model per use case. Specialization beats a shared model reused across
|
|
71
|
+
conflicting scenarios.
|
|
72
|
+
- Do not give read-model properties default values that could mask a projection
|
|
73
|
+
that was never wired.
|
|
74
|
+
- Never name a property `_subject`, `__subject`, or `__subjects` — Chronicle
|
|
75
|
+
reserves those for compliance-subject tracking, and analyzer `CHR0035`
|
|
76
|
+
rejects them.
|
|
77
|
+
- `Cratis.Chronicle.ReadModels.IndexAttribute` (`[Index]`) marks a property for
|
|
78
|
+
indexing in the sink.
|
|
79
|
+
|
|
80
|
+
## Step 3 — Choose a projection or a reducer
|
|
81
|
+
|
|
82
|
+
| | Projection | Reducer |
|
|
83
|
+
| --- | --- | --- |
|
|
84
|
+
| Shape | the model is mostly a mapping of event fields | the next state is a function of the current state |
|
|
85
|
+
| Expressed as | model-bound attributes, or the fluent builder | C# control flow in a handler method |
|
|
86
|
+
| Reach for it | first, and second | only after both projection forms fail |
|
|
87
|
+
|
|
88
|
+
A reducer is a legitimate style when the transition genuinely depends on prior
|
|
89
|
+
state — it is not a failure mode. It *is* a failure mode when it re-implements
|
|
90
|
+
something an attribute already expresses. Work down the ladder: model-bound
|
|
91
|
+
attributes, then the fluent builder, then a reducer.
|
|
92
|
+
|
|
93
|
+
Both are discovered by convention. Nothing is registered.
|
|
94
|
+
|
|
95
|
+
Use the projection skill for the attribute and builder surface, and the reducer
|
|
96
|
+
skill for handler signatures and the nullable-current rule.
|
|
97
|
+
|
|
98
|
+
## Step 4 — Add a query surface, if the model is read from outside
|
|
99
|
+
|
|
100
|
+
Queries are **static** methods on the read-model record, discovered by Arc.
|
|
101
|
+
Marking the record with `[ReadModel]` from `Cratis.Arc.Queries.ModelBound` is
|
|
102
|
+
what gives it that surface.
|
|
103
|
+
|
|
104
|
+
```csharp
|
|
105
|
+
using System.Reactive.Subjects;
|
|
106
|
+
using Cratis.Arc.Queries.ModelBound;
|
|
107
|
+
using Cratis.Chronicle.Keys;
|
|
108
|
+
using MongoDB.Driver;
|
|
109
|
+
|
|
110
|
+
[ReadModel]
|
|
111
|
+
public record <ReadModelName>([property: Key] <IdType> Id, <PropertyType> <PropertyName>)
|
|
112
|
+
{
|
|
113
|
+
public static async Task<IEnumerable<<ReadModelName>>> All<PluralName>(
|
|
114
|
+
IMongoCollection<<ReadModelName>> collection) =>
|
|
115
|
+
await collection.Find(Builders<<ReadModelName>>.Filter.Empty).ToListAsync();
|
|
116
|
+
|
|
117
|
+
public static ISubject<IEnumerable<<ReadModelName>>> Observe<PluralName>(
|
|
118
|
+
IMongoCollection<<ReadModelName>> collection) =>
|
|
119
|
+
collection.Observe();
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The rules Arc's discovery actually enforces:
|
|
124
|
+
|
|
125
|
+
- The method must be **static**. Instance methods are never discovered.
|
|
126
|
+
- The declared return type, after unwrapping one `Task<>`, must be the read
|
|
127
|
+
model, an array of it, something assignable to `IEnumerable<T>` of it,
|
|
128
|
+
`IAsyncEnumerable<T>` of it, `ISubject<T>` of it, or `ISubject<>` of a
|
|
129
|
+
collection of it.
|
|
130
|
+
- **The declared type must be `ISubject<...>`, never `Subject<...>` or
|
|
131
|
+
`BehaviorSubject<...>`.** The check matches the open generic definition
|
|
132
|
+
exactly, so a concrete subject type fails discovery silently. Construct a
|
|
133
|
+
concrete subject inside if you like; declare `ISubject<...>`.
|
|
134
|
+
- Open generic methods are rejected.
|
|
135
|
+
- Use `[Path("<route>")]` from `Cratis.Arc.Queries.ModelBound` for a custom
|
|
136
|
+
route. It targets the class or the method. It is Arc's attribute — do not
|
|
137
|
+
reach for ASP.NET's `[Route]`.
|
|
138
|
+
|
|
139
|
+
Read [references/queries.md](references/queries.md) for the `Observe` family and
|
|
140
|
+
the difference between snapshot and observable queries.
|
|
141
|
+
|
|
142
|
+
## Step 5 — Or read the instance directly
|
|
143
|
+
|
|
144
|
+
Inside the backend, read an instance through
|
|
145
|
+
`Cratis.Chronicle.ReadModels.IReadModels`, reached from `IEventStore.ReadModels`:
|
|
146
|
+
|
|
147
|
+
```csharp
|
|
148
|
+
Task<TReadModel> GetInstanceById<TReadModel>(ReadModelKey key, ReadModelSessionId? sessionId = null);
|
|
149
|
+
Task<object> GetInstanceById(Type readModelType, ReadModelKey key, ReadModelSessionId? sessionId = null);
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`ReadModelKey` converts implicitly from `string`, `Guid`, and `EventSourceId`,
|
|
153
|
+
so an id can be passed directly. **The declared return type is not nullable even
|
|
154
|
+
though no instance may exist** — check the result before dereferencing it.
|
|
155
|
+
|
|
156
|
+
Neighbouring members include `GetInstances<T>`, `GetSnapshotsById<T>`,
|
|
157
|
+
`Watch<T>`, `Release<T>`, and `Register`. **There is no predicate or `IQueryable`
|
|
158
|
+
surface on `IReadModels`** — filtered reads go through the sink, which is what
|
|
159
|
+
the Arc query methods above do.
|
|
160
|
+
|
|
161
|
+
### `[Passive]` when the read is a command-side decision
|
|
162
|
+
|
|
163
|
+
`Cratis.Chronicle.ReadModels.PassiveAttribute` on the read-model type makes it
|
|
164
|
+
passive: no sink, nothing materialized, and the instance is computed on demand
|
|
165
|
+
from the events at the moment it is read. Reach for it when a decision needs the
|
|
166
|
+
model **strongly consistent** rather than eventually consistent.
|
|
167
|
+
|
|
168
|
+
`[Passive]` on its own does **not** make a read model a projection. It is
|
|
169
|
+
deliberately excluded from the annotations that do; it changes how an existing
|
|
170
|
+
projection is registered.
|
|
171
|
+
|
|
172
|
+
## Step 6 — Build and verify
|
|
173
|
+
|
|
174
|
+
Build, fix every error, then prove the behavior: drive the contributing events
|
|
175
|
+
through the read-model specification for this model and assert the projected
|
|
176
|
+
state. Seed each contributing stream with its own event source when the model
|
|
177
|
+
spans streams.
|
|
178
|
+
|
|
179
|
+
## Verify
|
|
180
|
+
|
|
181
|
+
- Every event is a past-tense fact with one purpose and no nullable properties.
|
|
182
|
+
- No event carries its own event-source id.
|
|
183
|
+
- The read model has exactly one key, and no `[Key]` sits on an
|
|
184
|
+
`EventSourceId<T>` value.
|
|
185
|
+
- No reserved compliance-subject property name is used.
|
|
186
|
+
- Read-model properties carry no defaults that could hide unwired projection.
|
|
187
|
+
- Query methods are `static` and declare `ISubject<...>` rather than a concrete
|
|
188
|
+
subject type.
|
|
189
|
+
- A direct `GetInstanceById` result is checked for absence before use.
|
|
190
|
+
- `[Passive]` is present only where a strongly consistent command-side read is
|
|
191
|
+
genuinely required.
|
|
192
|
+
- The project builds clean and the read-model specifications pass against the
|
|
193
|
+
verified package versions.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Read-model queries
|
|
2
|
+
|
|
3
|
+
Verified against `Cratis.Arc.Core` and `Cratis.Arc.MongoDB` as checked out
|
|
4
|
+
alongside `Cratis.Chronicle` `16.45.2`. Confirm the exact Arc package version in
|
|
5
|
+
the consuming project before citing one.
|
|
6
|
+
|
|
7
|
+
## Where the pieces live
|
|
8
|
+
|
|
9
|
+
| Type | Namespace | Package |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| `ReadModelAttribute` (`[ReadModel]`) | `Cratis.Arc.Queries.ModelBound` | `Cratis.Arc.Core` |
|
|
12
|
+
| `PathAttribute` (`[Path]`) | `Cratis.Arc.Queries.ModelBound` | `Cratis.Arc.Core` |
|
|
13
|
+
| the `Observe` extensions | `MongoDB.Driver` | `Cratis.Arc.MongoDB` |
|
|
14
|
+
|
|
15
|
+
`[ReadModel]` is a bare marker with **no constructor parameters**. `[Path]` takes
|
|
16
|
+
one `string path` and targets a class or a method.
|
|
17
|
+
|
|
18
|
+
The `Observe` extensions are deliberately declared in the `MongoDB.Driver`
|
|
19
|
+
namespace so they resolve from an existing `using MongoDB.Driver;`. They are not
|
|
20
|
+
Chronicle extensions — Chronicle declares no `Observe` on `IMongoCollection<T>`.
|
|
21
|
+
|
|
22
|
+
## Discovery rules
|
|
23
|
+
|
|
24
|
+
Arc scans the read-model type for **static** methods, public and non-public
|
|
25
|
+
alike. Instance methods are never discovered.
|
|
26
|
+
|
|
27
|
+
The declared return type is validated after unwrapping at most one `Task<>`:
|
|
28
|
+
|
|
29
|
+
| Accepted |
|
|
30
|
+
| --- |
|
|
31
|
+
| the read model type |
|
|
32
|
+
| an array of it |
|
|
33
|
+
| anything assignable to `IEnumerable<T>` of it |
|
|
34
|
+
| `IAsyncEnumerable<T>` of it |
|
|
35
|
+
| `ISubject<T>` of it |
|
|
36
|
+
| `ISubject<T>` of a collection of it |
|
|
37
|
+
|
|
38
|
+
Rejected: open generic methods, and **any concrete subject type**. The subject
|
|
39
|
+
check matches `typeof(ISubject<>)` as an open generic definition, so a method
|
|
40
|
+
declared as returning `Subject<T>` or `BehaviorSubject<T>` fails discovery
|
|
41
|
+
without an error at the call site. Declare `ISubject<...>`; construct whatever
|
|
42
|
+
you like inside.
|
|
43
|
+
|
|
44
|
+
## Snapshot queries
|
|
45
|
+
|
|
46
|
+
A snapshot query answers once with the current state.
|
|
47
|
+
|
|
48
|
+
```csharp
|
|
49
|
+
public static async Task<IEnumerable<<ReadModelName>>> All<PluralName>(
|
|
50
|
+
IMongoCollection<<ReadModelName>> collection) =>
|
|
51
|
+
await collection.Find(Builders<<ReadModelName>>.Filter.Empty).ToListAsync();
|
|
52
|
+
|
|
53
|
+
public static async Task<<ReadModelName>?> <SingularName>ById(
|
|
54
|
+
<IdType> id,
|
|
55
|
+
IMongoCollection<<ReadModelName>> collection) =>
|
|
56
|
+
await collection.Find(model => model.Id == id).FirstOrDefaultAsync();
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Name the method for what it returns — the name is the query's identity on the
|
|
60
|
+
generated client surface.
|
|
61
|
+
|
|
62
|
+
## Observable queries
|
|
63
|
+
|
|
64
|
+
An observable query pushes updates as projected state lands.
|
|
65
|
+
|
|
66
|
+
```csharp
|
|
67
|
+
public static ISubject<IEnumerable<<ReadModelName>>> Observe<PluralName>(
|
|
68
|
+
IMongoCollection<<ReadModelName>> collection) =>
|
|
69
|
+
collection.Observe();
|
|
70
|
+
|
|
71
|
+
public static ISubject<<ReadModelName>> Observe<SingularName>ById(
|
|
72
|
+
<IdType> id,
|
|
73
|
+
IMongoCollection<<ReadModelName>> collection) =>
|
|
74
|
+
collection.ObserveById<<ReadModelName>, <IdType>>(id);
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Return `ISubject<...>` directly. Do **not** return `Task<ISubject<...>>`.
|
|
78
|
+
|
|
79
|
+
## The `Observe` family — exact signatures
|
|
80
|
+
|
|
81
|
+
```csharp
|
|
82
|
+
// collection-valued
|
|
83
|
+
ISubject<IEnumerable<TDocument>> Observe<TDocument>(
|
|
84
|
+
this IMongoCollection<TDocument> collection,
|
|
85
|
+
Expression<Func<TDocument, bool>>? filter,
|
|
86
|
+
FindOptions? options = null);
|
|
87
|
+
|
|
88
|
+
ISubject<IEnumerable<TDocument>> Observe<TDocument>(
|
|
89
|
+
this IMongoCollection<TDocument> collection,
|
|
90
|
+
FilterDefinition<TDocument>? filter = null,
|
|
91
|
+
FindOptions? options = null);
|
|
92
|
+
|
|
93
|
+
// single-valued
|
|
94
|
+
ISubject<TDocument> ObserveSingle<TDocument>(
|
|
95
|
+
this IMongoCollection<TDocument> collection,
|
|
96
|
+
Expression<Func<TDocument, bool>>? filter,
|
|
97
|
+
FindOptions? options = null);
|
|
98
|
+
|
|
99
|
+
ISubject<TDocument> ObserveSingle<TDocument>(
|
|
100
|
+
this IMongoCollection<TDocument> collection,
|
|
101
|
+
FilterDefinition<TDocument>? filter = null,
|
|
102
|
+
FindOptions? options = null);
|
|
103
|
+
|
|
104
|
+
ISubject<TDocument> ObserveById<TDocument, TId>(
|
|
105
|
+
this IMongoCollection<TDocument> collection,
|
|
106
|
+
TId id);
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Two details that catch people out:
|
|
110
|
+
|
|
111
|
+
- **`Observe` always returns a collection**, even with an equality filter. For a
|
|
112
|
+
single document use `ObserveSingle` or `ObserveById`; a filtered `Observe`
|
|
113
|
+
declared as `ISubject<TDocument>` does not compile.
|
|
114
|
+
- On the expression overload the `filter` parameter has **no default**. A bare
|
|
115
|
+
`collection.Observe()` therefore binds the `FilterDefinition<TDocument>?`
|
|
116
|
+
overload, which is the intended "everything" call.
|
|
117
|
+
|
|
118
|
+
Also available in the same place: `FindById<T, TId>` and
|
|
119
|
+
`FindByIdAsync<T, TId>`.
|
|
120
|
+
|
|
121
|
+
## Custom routes
|
|
122
|
+
|
|
123
|
+
```csharp
|
|
124
|
+
[ReadModel]
|
|
125
|
+
[Path("<resource-path>")]
|
|
126
|
+
public record <ReadModelName>(...)
|
|
127
|
+
{
|
|
128
|
+
[Path("<sub-path>")]
|
|
129
|
+
public static Task<IEnumerable<<ReadModelName>>> <QueryName>(...) => ...;
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`[Path]` is Arc's attribute and works at class or method level. ASP.NET's
|
|
134
|
+
`[Route]` belongs to controller-based endpoints and is not the mechanism here.
|
|
135
|
+
|
|
136
|
+
## Reading without a query surface
|
|
137
|
+
|
|
138
|
+
When only backend code reads the model, skip `[ReadModel]` entirely and use
|
|
139
|
+
`IEventStore.ReadModels.GetInstanceById<T>(key)`. Its declared return type is
|
|
140
|
+
non-nullable even though no instance may exist, so test the result. There is no
|
|
141
|
+
predicate or `IQueryable` surface on `IReadModels` — filtered reads belong on the
|
|
142
|
+
sink, which is what the query methods above use.
|