@cratis/pi 2.25.0 → 2.27.0
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/package/corpus/prompts/ship-changes.prompt.md +7 -0
- package/package/corpus/rules/pull-requests.md +25 -0
- package/package/corpus/skills/cratis-chronicle-projection/SKILL.md +65 -1
- package/package/corpus/skills/cratis-chronicle-projection/references/fluent-builder.md +12 -1
- package/package/corpus/skills/cratis-chronicle-projection/references/model-bound-attributes.md +13 -0
- package/package/corpus/skills/cratis-chronicle-projection/references/variants.md +182 -0
- package/package.json +1 -1
|
@@ -22,6 +22,13 @@ requested-label, merge, and branch-cleanup effects. Do not pause to ask for sepa
|
|
|
22
22
|
each step. Follow the repository's Git commit and pull-request rules, use a true merge commit,
|
|
23
23
|
and verify required checks before merging.
|
|
24
24
|
|
|
25
|
+
**Exception: a `major` label does not carry merge authority.** Prepare the branch, commits, push,
|
|
26
|
+
and PR labeled `major` as usual, then stop before merging. Ask a human to confirm the breaking
|
|
27
|
+
change(s), who is affected, and the resulting version number, and merge only on an explicit
|
|
28
|
+
affirmative answer to that specific question — see
|
|
29
|
+
[`pull-requests.md`](../rules/pull-requests.md#a-major-release-needs-a-humans-explicit-go-ahead).
|
|
30
|
+
Every other label proceeds through merge under this prompt's ordinary authority.
|
|
31
|
+
|
|
25
32
|
This prompt is for an explicit request to ship or land. If the user asked only to commit, only to
|
|
26
33
|
push, or only to open a PR, stop after that step: the narrower request does not become authority
|
|
27
34
|
for the rest of the chain because this prompt happens to be loaded.
|
|
@@ -37,6 +37,31 @@ Confirm the current repository workflow contract before selecting release intent
|
|
|
37
37
|
- **minor** — new features, new slices, non-breaking additions
|
|
38
38
|
- **patch** — bug fixes, refactoring with identical behavior
|
|
39
39
|
|
|
40
|
+
### A major release needs a human's explicit go-ahead
|
|
41
|
+
|
|
42
|
+
A `major` label is the one release-intent label a ship request does not, by
|
|
43
|
+
itself, authorize through to merge. Breaking a public API is the most
|
|
44
|
+
consequential and hardest-to-reverse thing a release does — every downstream
|
|
45
|
+
consumer eventually has to act on it — so it gets a checkpoint the other two
|
|
46
|
+
intents do not.
|
|
47
|
+
|
|
48
|
+
- Prepare the branch, commits, push, and PR carrying the `major` label exactly
|
|
49
|
+
as any other ship request would.
|
|
50
|
+
- Before merging, stop and ask a human to confirm the major bump specifically:
|
|
51
|
+
name the exact breaking change(s), who is affected and how, and the
|
|
52
|
+
resulting version number. A generic "ready to ship?" is not this checkpoint —
|
|
53
|
+
say plainly that this release breaks compatibility and needs a yes.
|
|
54
|
+
- Proceed to merge only on an explicit, affirmative answer to that question.
|
|
55
|
+
A prior general instruction to "ship" or "land this" does not answer it,
|
|
56
|
+
even when it named `major` as the intended label.
|
|
57
|
+
- This checkpoint is per release, not per conversation — a human confirming
|
|
58
|
+
one major release does not pre-authorize the next one.
|
|
59
|
+
|
|
60
|
+
This narrows the general ship-changes authorization in
|
|
61
|
+
[`ship-changes.prompt.md`](../prompts/ship-changes.prompt.md) for exactly this
|
|
62
|
+
label; every other step of that workflow proceeds under its existing
|
|
63
|
+
authority.
|
|
64
|
+
|
|
40
65
|
### A pull request that changes nothing outward-facing carries `no-release`
|
|
41
66
|
|
|
42
67
|
**If nothing in the PR can change what a consumer compiles against, runs, or observes, propose repository-supported non-release intent, ordinarily `no-release`** — not `patch`. Confirm that the current workflow supports the label, requires exactly one release-intent label, and suppresses publication as intended before applying it with authorization.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: cratis-chronicle-projection
|
|
3
|
-
description: Add Chronicle projection behavior to an existing read model - model-bound attributes first, the fluent IProjectionFor<T> builder when they cannot express the shape. Covers AutoMap, keys, children and nested types, counters, event-sequence selection, and the startup-crash traps. Use when populating a read model from events; do not use to create the read model and its query surface, and do not reach for a reducer before exhausting these options.
|
|
3
|
+
description: Add Chronicle projection behavior to an existing read model - model-bound attributes first, the fluent IProjectionFor<T> builder when they cannot express the shape. Covers AutoMap, keys, children and nested types, counters, event-sequence selection, variants ([VariantOf<T>]/.VariantOf<T>() and [EntersOn<T>]/.EntersOn<T>()) for an entity with mutually exclusive lifecycle shapes such as a work item that is a backlog entry then a pull request then closed, and the startup-crash traps. Use when populating a read model from events; do not use to create the read model and its query surface, and do not reach for a reducer before exhausting these options.
|
|
4
4
|
license: MIT
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -104,6 +104,64 @@ query edge. Chronicle's analyzer **`CHR0042` (`JoinOverridesLocalWrite`)** repor
|
|
|
104
104
|
the overlap; suppress it only when the join is deliberately keeping that property
|
|
105
105
|
fresh.
|
|
106
106
|
|
|
107
|
+
## Variants — mutually exclusive shapes for one entity's lifecycle
|
|
108
|
+
|
|
109
|
+
Some entities do not have one shape for their whole lifetime — a work item is a
|
|
110
|
+
backlog entry until a pull request exists for it, then it is a pull request
|
|
111
|
+
until it merges. Modeling that as one read model with a `Status` property and
|
|
112
|
+
an ever-growing set of nullable columns makes every query filter on state
|
|
113
|
+
before it can trust a field. **Variants** let you declare several mutually
|
|
114
|
+
exclusive read models for the same logical entity instead — entering one
|
|
115
|
+
variant automatically removes the entity from every other variant in the
|
|
116
|
+
group, so at any point an entity exists in exactly one of them. Reach for
|
|
117
|
+
variants only when the shapes genuinely diverge; a single read model with a
|
|
118
|
+
`Status` flag is still right when every stage shares almost all its
|
|
119
|
+
properties.
|
|
120
|
+
|
|
121
|
+
> Variants require `Cratis.Chronicle` `19.1.0` or later — newer than this
|
|
122
|
+
> skill's `18.3.0` baseline. Reverify before claiming support.
|
|
123
|
+
|
|
124
|
+
Both approaches use the identity type only to anchor the group — it does not
|
|
125
|
+
need to be a read model itself, and does not need a common CLR base type with
|
|
126
|
+
any of the variants.
|
|
127
|
+
|
|
128
|
+
**Model-bound:** `[VariantOf<TIdentity>]` on each variant type, `[EntersOn<TEvent>]`
|
|
129
|
+
naming the event that activates it, and an optional `[GlobalFor<TIdentity>]` type
|
|
130
|
+
carrying mappings shared across every variant.
|
|
131
|
+
|
|
132
|
+
```csharp
|
|
133
|
+
[VariantOf<WorkItem>]
|
|
134
|
+
[EntersOn<IssueCreated>]
|
|
135
|
+
public record BacklogItem([property: Key] Guid Id, string Title);
|
|
136
|
+
|
|
137
|
+
[VariantOf<WorkItem>]
|
|
138
|
+
[EntersOn<PullRequestCreated>]
|
|
139
|
+
public record PullRequestItem([property: Key] Guid Id, [property: SetFrom<PullRequestCreated>] string PullRequestUrl);
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**Fluent:** `.VariantOf<TIdentity>(keyAccessor)` and `.EntersOn<TEvent>()` are
|
|
143
|
+
members of `IProjectionBuilderFor<TReadModel>` itself — there is no
|
|
144
|
+
`[GlobalFor<T>]` equivalent, so a shared mapping is declared with `.From<TEvent>()`
|
|
145
|
+
on each variant's builder.
|
|
146
|
+
|
|
147
|
+
```csharp
|
|
148
|
+
public void Define(IProjectionBuilderFor<PullRequestItem> builder) => builder
|
|
149
|
+
.VariantOf<WorkItem>(_ => _.Id)
|
|
150
|
+
.EntersOn<PullRequestCreated>();
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Only the entering event can create or resurrect a variant. Every other event a
|
|
154
|
+
variant handles — declared exactly like an ordinary `.From<TEvent>()` /
|
|
155
|
+
`[SetFrom<T>]` mapping — is automatically reclassified into an update-only
|
|
156
|
+
join, so an out-of-order or replayed event can never resurrect a variant the
|
|
157
|
+
entity has since left. A variant that declares no entering event at all throws
|
|
158
|
+
`VariantMustDeclareEntersOnEvent` at startup.
|
|
159
|
+
|
|
160
|
+
Read [references/variants.md](references/variants.md) for the complete group
|
|
161
|
+
declaration, the update-only reclassification in full, `[GlobalFor<T>]` rules
|
|
162
|
+
and its declaration-error trap, and best practices for picking an identity
|
|
163
|
+
type.
|
|
164
|
+
|
|
107
165
|
## Selecting the source
|
|
108
166
|
|
|
109
167
|
Class-level `[EventSequence("<name>")]`, `[EventLog]`, or `[EventStore("<name>")]`
|
|
@@ -146,6 +204,10 @@ or a reactor rather than annotating the projection.
|
|
|
146
204
|
- **Chaining after `AutoMap()`/`NoAutoMap()`.** Those two return the base builder
|
|
147
205
|
interface, so `.NotRewindable()`, `.Passive()`, `.ContainerName()`, and
|
|
148
206
|
`.FromEventSequence()` will not compile after them. Put them last.
|
|
207
|
+
- **A variant with no `[EntersOn<T>]` / `.EntersOn<T>()`.** Throws
|
|
208
|
+
`VariantMustDeclareEntersOnEvent` at startup — a variant that could never be
|
|
209
|
+
entered could never be written to at all. See
|
|
210
|
+
[references/variants.md](references/variants.md).
|
|
149
211
|
|
|
150
212
|
## `[FromAll]` versus `[FromEvery]`
|
|
151
213
|
|
|
@@ -177,5 +239,7 @@ projection spans streams.
|
|
|
177
239
|
- `ConstantKey` is written as a property initializer.
|
|
178
240
|
- The same event does not carry class-level `[FromEvent<T>]` on both a parent and
|
|
179
241
|
a nested or child type.
|
|
242
|
+
- Every variant group has at least one `[EntersOn<T>]` / `.EntersOn<T>()` per
|
|
243
|
+
variant, and every `[GlobalFor<T>]` member exists on every variant it targets.
|
|
180
244
|
- The project builds clean and the read-model specifications pass against the
|
|
181
245
|
verified package version.
|
|
@@ -21,15 +21,26 @@ member that Chronicle does not read. Use
|
|
|
21
21
|
`[Projection(id: "<id>", eventSequence: "<name>")]` when the identity or source
|
|
22
22
|
sequence must be explicit.
|
|
23
23
|
|
|
24
|
-
## `IProjectionBuilderFor<TReadModel>` — its own
|
|
24
|
+
## `IProjectionBuilderFor<TReadModel>` — its own members
|
|
25
25
|
|
|
26
26
|
```csharp
|
|
27
27
|
IProjectionBuilderFor<TReadModel> FromEventSequence(EventSequenceId eventSequenceId);
|
|
28
28
|
IProjectionBuilderFor<TReadModel> ContainerName(string containerName);
|
|
29
29
|
IProjectionBuilderFor<TReadModel> NotRewindable();
|
|
30
30
|
IProjectionBuilderFor<TReadModel> Passive();
|
|
31
|
+
IProjectionBuilderFor<TReadModel> VariantOf<TIdentity>(Expression<Func<TReadModel, object?>> keyAccessor);
|
|
32
|
+
IProjectionBuilderFor<TReadModel> EntersOn<TEvent>();
|
|
31
33
|
```
|
|
32
34
|
|
|
35
|
+
`VariantOf<TIdentity>` and `EntersOn<TEvent>` require `Cratis.Chronicle`
|
|
36
|
+
`19.1.0` or later — newer than this file's `18.3.0` baseline. See
|
|
37
|
+
[variants.md](variants.md) for the full mechanics: the identity type anchors a
|
|
38
|
+
group of mutually exclusive read models, only the event named in `EntersOn`
|
|
39
|
+
can create or resurrect the variant, and every other `.From<TEvent>()` on the
|
|
40
|
+
same builder is automatically reclassified into an update-only join. There is
|
|
41
|
+
no fluent equivalent of the model-bound `[GlobalFor<T>]` shared handler — a
|
|
42
|
+
mapping shared across variants is repeated on each variant's builder.
|
|
43
|
+
|
|
33
44
|
## Inherited from `IProjectionBuilder<TReadModel, TBuilder>`
|
|
34
45
|
|
|
35
46
|
```csharp
|
package/package/corpus/skills/cratis-chronicle-projection/references/model-bound-attributes.md
CHANGED
|
@@ -70,6 +70,19 @@ Setting it routes every matching event into a single aggregating document at
|
|
|
70
70
|
that key. That is a different mechanism from the fluent builder's
|
|
71
71
|
`UsingConstantKey("<key>")`, even though the effect is similar.
|
|
72
72
|
|
|
73
|
+
## Variants
|
|
74
|
+
|
|
75
|
+
| Attribute | Arity | Parameters | Targets |
|
|
76
|
+
| --- | --- | --- | --- |
|
|
77
|
+
| `[VariantOf<TIdentity>]` | `<TIdentity>` | none | class, struct; single |
|
|
78
|
+
| `[EntersOn<T>]` | `<T>` | `(string? key = default)` | class, struct; repeatable |
|
|
79
|
+
| `[GlobalFor<TIdentity>]` | `<TIdentity>` | none | class, struct; single |
|
|
80
|
+
|
|
81
|
+
Requires `Cratis.Chronicle` `19.1.0` or later. See
|
|
82
|
+
[variants.md](variants.md) for the full mechanics, the update-only
|
|
83
|
+
reclassification of non-entering events, and the `VariantMustDeclareEntersOnEvent`
|
|
84
|
+
startup trap.
|
|
85
|
+
|
|
73
86
|
## Relationships
|
|
74
87
|
|
|
75
88
|
| Attribute | Arity | Parameters | Targets |
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# Variants — mutually exclusive read models for one entity
|
|
2
|
+
|
|
3
|
+
Verified against `Cratis.Chronicle` `19.1.0` (first version carrying this
|
|
4
|
+
feature; the skill's own baseline in `SKILL.md` is `18.3.0` — variants need at
|
|
5
|
+
least `19.1.0`).
|
|
6
|
+
|
|
7
|
+
Reach for variants when a single logical entity has genuinely different shapes
|
|
8
|
+
across its lifecycle — a work item that is a backlog entry, then a pull
|
|
9
|
+
request, then closed — and modeling it as one read model would mean a `Status`
|
|
10
|
+
column plus an ever-growing set of properties that are only meaningful in one
|
|
11
|
+
stage. A single read model with a `Status` flag is still correct when the
|
|
12
|
+
stages differ only by a flag or two; reach for variants only when the shapes
|
|
13
|
+
themselves diverge. See `choosing-a-read-model-style` in the Chronicle docs for
|
|
14
|
+
the wider decision.
|
|
15
|
+
|
|
16
|
+
Each variant is an ordinary projection — model-bound or fluent — with its own
|
|
17
|
+
shape. Entering one variant automatically removes the entity from every other
|
|
18
|
+
variant in the same group, so at any point an entity exists in exactly one
|
|
19
|
+
variant. There is no CLR relationship required between the variants or the
|
|
20
|
+
identity type; the identity type's only job is to anchor the group.
|
|
21
|
+
|
|
22
|
+
## Declaring a group — model-bound
|
|
23
|
+
|
|
24
|
+
```csharp
|
|
25
|
+
using Cratis.Chronicle.Events;
|
|
26
|
+
using Cratis.Chronicle.Keys;
|
|
27
|
+
using Cratis.Chronicle.Projections.ModelBound;
|
|
28
|
+
|
|
29
|
+
[EventType]
|
|
30
|
+
public record IssueCreated(string Title);
|
|
31
|
+
|
|
32
|
+
[EventType]
|
|
33
|
+
public record PullRequestCreated(string PullRequestUrl);
|
|
34
|
+
|
|
35
|
+
// Anchors the group. Does not need to be a read model itself, and does not
|
|
36
|
+
// need a common CLR base type with any of the variants.
|
|
37
|
+
public class WorkItem;
|
|
38
|
+
|
|
39
|
+
[VariantOf<WorkItem>]
|
|
40
|
+
[EntersOn<IssueCreated>]
|
|
41
|
+
public record BacklogItem([property: Key] Guid Id, string Title);
|
|
42
|
+
|
|
43
|
+
[VariantOf<WorkItem>]
|
|
44
|
+
[EntersOn<PullRequestCreated>]
|
|
45
|
+
public record PullRequestItem([property: Key] Guid Id, [property: SetFrom<PullRequestCreated>] string PullRequestUrl);
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`[VariantOf<TIdentity>]` targets a class or struct, is not repeatable, and
|
|
49
|
+
lives in `Cratis.Chronicle.Projections.ModelBound`. `TIdentity` is what groups
|
|
50
|
+
variants — every type marked `[VariantOf<WorkItem>]` is mutually exclusive with
|
|
51
|
+
every other. `[EntersOn<TEvent>]` targets a class or struct, **is repeatable**
|
|
52
|
+
(a variant can be entered by more than one event type), and takes an optional
|
|
53
|
+
`key` constructor argument naming the event property that identifies the
|
|
54
|
+
instance — it defaults to the event source id.
|
|
55
|
+
|
|
56
|
+
When `IssueCreated` is processed, a `BacklogItem` is created. When
|
|
57
|
+
`PullRequestCreated` is processed for the same entity, `PullRequestItem` is
|
|
58
|
+
created **and `BacklogItem` is removed automatically** — no `[RemovedWith<T>]`
|
|
59
|
+
needed, the group handles it. Variants do not have to be entered in order: if
|
|
60
|
+
`PullRequestCreated` arrives for an entity that was never a `BacklogItem`,
|
|
61
|
+
`PullRequestItem` is still created.
|
|
62
|
+
|
|
63
|
+
## Declaring a group — fluent (`IProjectionFor<T>`)
|
|
64
|
+
|
|
65
|
+
```csharp
|
|
66
|
+
using Cratis.Chronicle.Projections;
|
|
67
|
+
|
|
68
|
+
// Anchors the group, same rules as above.
|
|
69
|
+
public class WorkItem;
|
|
70
|
+
|
|
71
|
+
public record BacklogItem(Guid Id, string Title);
|
|
72
|
+
public record PullRequestItem(Guid Id, string PullRequestUrl);
|
|
73
|
+
|
|
74
|
+
public class BacklogItemProjection : IProjectionFor<BacklogItem>
|
|
75
|
+
{
|
|
76
|
+
public void Define(IProjectionBuilderFor<BacklogItem> builder) => builder
|
|
77
|
+
.VariantOf<WorkItem>(_ => _.Id)
|
|
78
|
+
.EntersOn<IssueCreated>();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public class PullRequestItemProjection : IProjectionFor<PullRequestItem>
|
|
82
|
+
{
|
|
83
|
+
public void Define(IProjectionBuilderFor<PullRequestItem> builder) => builder
|
|
84
|
+
.VariantOf<WorkItem>(_ => _.Id)
|
|
85
|
+
.EntersOn<PullRequestCreated>();
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`VariantOf<TIdentity>(Expression<Func<TReadModel, object?>> keyAccessor)` and
|
|
90
|
+
`EntersOn<TEvent>()` are members of `IProjectionBuilderFor<TReadModel>` itself
|
|
91
|
+
(`Cratis.Chronicle.Projections`), alongside `FromEventSequence`,
|
|
92
|
+
`ContainerName`, `NotRewindable`, and `Passive`. Unlike the model-bound
|
|
93
|
+
attribute, the fluent `VariantOf` call takes the key accessor explicitly —
|
|
94
|
+
there is no separate `[Key]` to infer it from. AutoMap still applies exactly as
|
|
95
|
+
for any other fluent projection.
|
|
96
|
+
|
|
97
|
+
## Only the entering event can create a variant
|
|
98
|
+
|
|
99
|
+
Every other `.From<TEvent>()` (fluent) or `[SetFrom<T>]` (model-bound) the
|
|
100
|
+
variant declares works exactly as it would on an ordinary projection — with one
|
|
101
|
+
difference: because the event is not the one named in `[EntersOn<T>]` /
|
|
102
|
+
`.EntersOn<T>()`, Chronicle automatically reclassifies that mapping into an
|
|
103
|
+
**update-only join** on the variant's own key before the definition reaches the
|
|
104
|
+
projection engine. It can update an already-active instance; it can never
|
|
105
|
+
create or resurrect one.
|
|
106
|
+
|
|
107
|
+
```csharp
|
|
108
|
+
// Model-bound
|
|
109
|
+
[VariantOf<WorkItem>]
|
|
110
|
+
[EntersOn<PullRequestCreated>]
|
|
111
|
+
public record PullRequestItem(
|
|
112
|
+
[property: Key] Guid Id,
|
|
113
|
+
[property: SetFrom<PullRequestCreated>] string PullRequestUrl,
|
|
114
|
+
[property: SetFrom<BuildCompleted>] string BuildStatus); // update-only: BuildCompleted is not the entering event
|
|
115
|
+
|
|
116
|
+
// Fluent
|
|
117
|
+
public class PullRequestItemProjection : IProjectionFor<PullRequestItem>
|
|
118
|
+
{
|
|
119
|
+
public void Define(IProjectionBuilderFor<PullRequestItem> builder) => builder
|
|
120
|
+
.VariantOf<WorkItem>(_ => _.Id)
|
|
121
|
+
.EntersOn<PullRequestCreated>()
|
|
122
|
+
.From<BuildCompleted>(); // update-only, same reason
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**Why this matters:** without the reclassification, an out-of-order or
|
|
127
|
+
replayed `BuildCompleted` could resurrect a `PullRequestItem` for an entity
|
|
128
|
+
that has since moved to another variant — or one that was never a pull request
|
|
129
|
+
at all. Declaring `[EntersOn<T>]` / `.EntersOn<T>()` is what makes that
|
|
130
|
+
structurally impossible; you do not have to reason about event ordering
|
|
131
|
+
yourself.
|
|
132
|
+
|
|
133
|
+
## Sharing handlers across variants
|
|
134
|
+
|
|
135
|
+
**Model-bound** has a dedicated mechanism: `[GlobalFor<TIdentity>]` on a type
|
|
136
|
+
carrying the shared mappings as ordinary members. Every mapping is merged into
|
|
137
|
+
every variant of `TIdentity`:
|
|
138
|
+
|
|
139
|
+
```csharp
|
|
140
|
+
[EventType]
|
|
141
|
+
public record TitleChanged(string Title);
|
|
142
|
+
|
|
143
|
+
[GlobalFor<WorkItem>]
|
|
144
|
+
public record WorkItemSharedHandlers([property: SetFrom<TitleChanged>] string Title);
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Every variant in the group **must** have the member a shared mapping targets —
|
|
148
|
+
a variant missing it is a declaration error caught when the projection is
|
|
149
|
+
discovered, not a silently skipped mapping at runtime. A shared handler is
|
|
150
|
+
always update-only, exactly like any non-entering event, so it is safe to
|
|
151
|
+
share across variants that enter at different times: it can never create or
|
|
152
|
+
resurrect one on its own.
|
|
153
|
+
|
|
154
|
+
**Fluent has no equivalent of `[GlobalFor<T>]`.** Each `IProjectionFor<T>` is
|
|
155
|
+
its own class producing its own definition, so a mapping every variant needs is
|
|
156
|
+
declared with `.From<TEvent>()` on each variant's builder individually. If that
|
|
157
|
+
repetition becomes a maintenance concern, that is itself a reason to reach for
|
|
158
|
+
model-bound projections for that group instead.
|
|
159
|
+
|
|
160
|
+
## Startup-crash trap
|
|
161
|
+
|
|
162
|
+
**A variant with no `[EntersOn<T>]` / `.EntersOn<T>()` throws
|
|
163
|
+
`VariantMustDeclareEntersOnEvent` at startup.** A variant that could never be
|
|
164
|
+
entered could never be created — and because every other handler on a variant
|
|
165
|
+
is update-only, it would silently never be written to at all, so Chronicle
|
|
166
|
+
refuses to start rather than let that pass silently.
|
|
167
|
+
|
|
168
|
+
## Best practices
|
|
169
|
+
|
|
170
|
+
1. **Pick an identity type that means something on its own** — not a marker
|
|
171
|
+
interface with no purpose beyond grouping. It is the type every variant (and
|
|
172
|
+
every shared handler) points back to.
|
|
173
|
+
2. **Give every variant only the properties that stage of the entity actually
|
|
174
|
+
has.** A property every stage needs is either a `[GlobalFor<T>]` handler
|
|
175
|
+
(model-bound) or repeated on each variant's builder (fluent) — never a
|
|
176
|
+
reason to fall back to one shared shape with nullable columns.
|
|
177
|
+
3. **Reach for variants only when the shapes genuinely diverge.** A single read
|
|
178
|
+
model with a `Status` property is still correct when every stage shares
|
|
179
|
+
almost all of its properties and differs only in a flag or two.
|
|
180
|
+
4. **Each variant is its own collection; there is no query that spans a whole
|
|
181
|
+
group.** A caller that needs "this entity, whichever stage it's in" queries
|
|
182
|
+
each variant explicitly rather than assuming a single combined collection.
|