@cratis/pi 2.26.0 → 2.28.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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: cratis-chronicle-client-elixir
3
- description: Talk to a Chronicle server from an Elixir application with the cratis_chronicle Hex package - putting Chronicle.Client in a supervision tree, connection strings, use Chronicle.Events.EventType structs, Chronicle.append returning ok or error tuples, reactors with the @handles attribute and a handle/2 callback, model-bound read models, and the connection lifecycle phases and keepalive. Use when an OTP application appends to or observes a Chronicle event store. Do not use for the .NET, TypeScript, or Kotlin clients.
3
+ description: Talk to a Chronicle server from an Elixir application with the cratis_chronicle Hex package - putting Chronicle.Client in a supervision tree, connection strings, use Chronicle.Events.EventType structs, Chronicle.append returning ok or error tuples, reactors with the @handles attribute and a handle/2 callback, model-bound and declarative read models/projections, variant_of/enters_on/GlobalHandler for an entity with mutually exclusive lifecycle shapes, and the connection lifecycle phases and keepalive. Use when an OTP application appends to or observes a Chronicle event store. Do not use for the .NET, TypeScript, or Kotlin clients.
4
4
  license: MIT
5
5
  ---
6
6
 
@@ -100,7 +100,7 @@ All configuration is **child-spec keyword options**. There is no
100
100
  | `:namespace` | `"Default"` — capital D | `:160` |
101
101
  | `:discover` | `true` | `:161` |
102
102
  | `:otp_app` | none; falls back to scanning loaded modules | `:168` |
103
- | `:event_types`, `:migrations`, `:reactors`, `:reducers`, `:read_models`, `:projections`, `:seeders`, `:webhooks`, `:event_store_subscriptions` | `[]`, merged with discovered | `:186-197` |
103
+ | `:event_types`, `:migrations`, `:reactors`, `:reducers`, `:read_models`, `:projections`, `:seeders`, `:webhooks`, `:event_store_subscriptions`, `:global_handlers` | `[]`, merged with discovered | `:186-197` |
104
104
  | `:skip_tls_validation`, `:load_balancer`, `:grpc_options`, `:retry_attempts`, `:reconnect_base_delay`, `:reconnect_max_delay` | forwarded to the connection | — |
105
105
 
106
106
  > The moduledoc at `client.ex:81` says the namespace defaults to `"default"`.
@@ -311,6 +311,78 @@ A standalone projection is `use Chronicle.Projections.Projection, model: Mod`
311
311
  `reduce(event, model_or_nil, context) :: struct()`. **Reducers run in your
312
312
  process**, so the reduction is Elixir code you own.
313
313
 
314
+ #### Variants — mutually exclusive read models for one entity's lifecycle
315
+
316
+ > Requires `cratis_chronicle` `3.4.0` or later — newer than this skill's
317
+ > `3.1.0` baseline (`lib/chronicle/projections/variant_reclassifier.ex` and
318
+ > siblings). Reverify before claiming support; take the version from hex.pm.
319
+
320
+ Some entities do not have one shape for their whole lifetime — a work item is a
321
+ backlog entry until a pull request exists for it, then it is a pull request
322
+ until it merges. `variant_of/2` and `enters_on/1,2` are macros imported by
323
+ **both** `use Chronicle.ReadModels.ReadModel` and
324
+ `use Chronicle.Projections.Projection`, so the model-bound and declarative
325
+ paths declare a variant identically:
326
+
327
+ ```elixir
328
+ defmodule MyApp.ReadModels.WorkItem do
329
+ end
330
+
331
+ defmodule MyApp.ReadModels.BacklogItem do
332
+ use Chronicle.ReadModels.ReadModel
333
+ defstruct id: nil, title: nil
334
+
335
+ variant_of MyApp.ReadModels.WorkItem, key: :id
336
+ enters_on MyApp.Events.IssueCreated
337
+
338
+ from MyApp.Events.IssueCreated, set: [id: :event_source_id, title: :title]
339
+ end
340
+
341
+ defmodule MyApp.ReadModels.PullRequestItem do
342
+ use Chronicle.ReadModels.ReadModel
343
+ defstruct id: nil, pull_request_url: nil, build_status: nil
344
+
345
+ variant_of MyApp.ReadModels.WorkItem, key: :id
346
+ enters_on MyApp.Events.PullRequestCreated
347
+
348
+ from MyApp.Events.PullRequestCreated,
349
+ set: [id: :event_source_id, pull_request_url: :pull_request_url]
350
+
351
+ # not the entering event -> automatically reclassified into an update-only join
352
+ from MyApp.Events.BuildCompleted, set: [build_status: :build_status]
353
+ end
354
+ ```
355
+
356
+ `variant_of/2` takes `:key` — **required** — the field on this variant that
357
+ carries the shared identity. `enters_on/1,2` is repeatable and its own `:key`
358
+ option names an *event* property (defaults to `:event_source_id`); every
359
+ `from`/`join` this variant declares for a non-entering event, whether declared
360
+ locally or merged from a shared handler, is automatically reclassified into an
361
+ update-only join keyed on `variant_of`'s `:key`.
362
+
363
+ A mapping shared across every variant of an identity is a
364
+ `Chronicle.Projections.GlobalHandler`, never registered as a projection on its
365
+ own:
366
+
367
+ ```elixir
368
+ defmodule MyApp.Projections.WorkItemTitleHandler do
369
+ use Chronicle.Projections.GlobalHandler, identity: MyApp.ReadModels.WorkItem
370
+
371
+ from MyApp.Events.TitleChanged, set: [title: :title]
372
+ end
373
+ ```
374
+
375
+ Register it explicitly with `global_handlers: [...]` on `Chronicle.Client`, or
376
+ let `:otp_app` auto-discovery find it (modules exporting
377
+ `__chronicle_global_handler__/1`). A mapping that targets a field some variant
378
+ lacks raises `Chronicle.Projections.GlobalHandlerPropertyNotOnVariant` at
379
+ registration, not a silently skipped mapping.
380
+
381
+ **A variant with no `enters_on` raises
382
+ `Chronicle.Projections.VariantMustDeclareEntersOnEvent`** at registration — a
383
+ variant that could never be entered could never be written to at all, since
384
+ every other handler on it is update-only.
385
+
314
386
  Querying (`lib/chronicle/read_models.ex`):
315
387
 
316
388
  ```elixir
@@ -416,6 +488,8 @@ attempt.
416
488
  | Appending from a `Task` and expecting the correlation id | Ambient context is per process |
417
489
  | Treating quiet observers as "no events" | Keepalive eviction silences observers while appends still succeed |
418
490
  | Shipping the default TLS behavior | `skip_tls_validation` defaults to `true` |
491
+ | Forgetting `global_handlers:` or `:otp_app` discovery | A `GlobalHandler` module is never merged into its variants unless registered one way or the other |
492
+ | A shared handler mapping a field one variant lacks | `GlobalHandlerPropertyNotOnVariant` at registration, not a silently skipped mapping |
419
493
 
420
494
  ## Verify
421
495
 
@@ -428,6 +502,8 @@ attempt.
428
502
  - Every `Chronicle.append/3` call site handles `{:error, _}` as well as `:ok`.
429
503
  - Every reactor's `@handles` list matches the clauses of its `handle/2`.
430
504
  - Read model module names have distinct final segments, or explicit ids.
505
+ - Every variant group has at least one `enters_on` per variant, and every
506
+ `GlobalHandler` member exists on every variant it targets.
431
507
  - Every code example was copied from `Documentation/client-snippets/`, not from
432
508
  the README.
433
509
  - `mix compile --warnings-as-errors` and `mix test` are clean.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: cratis-chronicle-client-kotlin
3
- description: Talk to a Chronicle server from a Kotlin or Java application with the io.cratis:chronicle client - connection strings, ChronicleClient and the Spring Boot starter, @EventType classes, suspending append, reactors and reducers dispatched by first-parameter type, model-bound read models, classpath artifact discovery, and the blocking API Java uses. Use when building a JVM application that appends to or observes a Chronicle event store. Do not use for the .NET, TypeScript, or Elixir clients, and do not use for Chronicle kernel or Arc work.
3
+ description: Talk to a Chronicle server from a Kotlin or Java application with the io.cratis:chronicle client - connection strings, ChronicleClient and the Spring Boot starter, @EventType classes, suspending append, reactors and reducers dispatched by first-parameter type, model-bound and declarative projections, variants (@VariantOf/@EntersOn/@GlobalFor or variantOf()/entersOn()) for an entity with mutually exclusive lifecycle shapes, classpath artifact discovery, and the blocking API Java uses. Use when building a JVM application that appends to or observes a Chronicle event store. Do not use for the .NET, TypeScript, or Elixir clients, and do not use for Chronicle kernel or Arc work.
4
4
  license: MIT
5
5
  ---
6
6
 
@@ -367,6 +367,84 @@ A model-bound projection puts the projection on the read model with `@FromEvent`
367
367
  `NotRewindable`. The declarative
368
368
  alternative is a class implementing `IProjectionFor<TReadModel>`.
369
369
 
370
+ ### Variants — mutually exclusive read models for one entity's lifecycle
371
+
372
+ > Requires `io.cratis:chronicle` `6.3.0` or later — newer than this skill's
373
+ > `5.1.0` baseline (`Source/src/main/kotlin/io/cratis/chronicle/projections/VariantOf.kt`
374
+ > and siblings). Reverify before claiming support; take the version from Maven
375
+ > Central, not the checked-in source.
376
+
377
+ Some entities do not have one shape for their whole lifetime — a work item is a
378
+ backlog entry until a pull request exists for it, then it is a pull request
379
+ until it merges. **Variants** let several read models share one logical
380
+ identity and be mutually exclusive: entering one variant removes the entity
381
+ from every other variant in the group, and only the event named by
382
+ `@EntersOn`/`entersOn(...)` can create or resurrect a variant — every other
383
+ event it handles is automatically reclassified into an update-only join.
384
+
385
+ **Model-bound** (the annotations are ordinary Java annotations, so Kotlin and
386
+ Java use the identical shape):
387
+
388
+ ```kotlin
389
+ class WorkItem // anchors the group; not itself a read model
390
+
391
+ @ReadModel
392
+ @VariantOf(WorkItem::class, key = "id")
393
+ @EntersOn(IssueCreated::class)
394
+ @FromEvent(IssueCreated::class)
395
+ data class BacklogItem(
396
+ @FromEventSourceId val id: String = "",
397
+ @SetFrom("title", IssueCreated::class) val title: String = ""
398
+ )
399
+
400
+ @ReadModel
401
+ @VariantOf(WorkItem::class, key = "id")
402
+ @EntersOn(PullRequestCreated::class)
403
+ @FromEvent(PullRequestCreated::class)
404
+ @FromEvent(BuildCompleted::class) // not the entering event -> update-only join
405
+ data class PullRequestItem(
406
+ @FromEventSourceId val id: String = "",
407
+ @SetFrom("pullRequestUrl", PullRequestCreated::class) val pullRequestUrl: String = "",
408
+ @SetFrom("buildStatus", BuildCompleted::class) val buildStatus: String = ""
409
+ )
410
+ ```
411
+
412
+ `@VariantOf(identity, key)` — **`key` is always an explicit property-name
413
+ string**, on both Kotlin and Java, because reflection resolves it rather than a
414
+ compile-time property reference. `@EntersOn(eventType, key = "EventSourceId")`
415
+ is repeatable — a variant may enter on more than one event — and its `key`
416
+ names an *event* property (not the read model's), defaulting to the event
417
+ source id. A mapping shared by every variant of an identity goes on a type
418
+ annotated `@GlobalFor(identity)` instead of being repeated per variant; every
419
+ variant it targets must actually have the member it maps, or
420
+ `GlobalHandlerPropertyNotOnVariant` is thrown at registration.
421
+
422
+ **Declarative** — `variantOf` and `entersOn` are members of
423
+ `IProjectionBuilderFor<TReadModel>` itself:
424
+
425
+ ```kotlin
426
+ class PullRequestItemProjection : IProjectionFor<PullRequestItem> {
427
+ override fun define(builder: IProjectionBuilderFor<PullRequestItem>) {
428
+ builder
429
+ .variantOf(WorkItem::class, PullRequestItem::id) // or variantOf(WorkItem::class, "id")
430
+ .entersOn(PullRequestCreated::class)
431
+ .from(BuildCompleted::class) // update-only, same reason
432
+ }
433
+ }
434
+ ```
435
+
436
+ Java calls the same builder with a `Class` and a string key — there is no
437
+ property-reference overload in Java:
438
+
439
+ ```java
440
+ builder.variantOf(WorkItem.class, "id").entersOn(PullRequestCreated.class);
441
+ ```
442
+
443
+ **A variant that declares no `@EntersOn`/`entersOn(...)` throws
444
+ `VariantMustDeclareEntersOnEvent`** at registration — a variant that could
445
+ never be entered could never be written to at all, since every other handler
446
+ on it is update-only.
447
+
370
448
  ## Discovery and registration
371
449
 
372
450
  `ClientArtifacts` scans the classpath with ClassGraph
@@ -492,6 +570,8 @@ only; there is no in-process reactor, projection, or constraint scenario
492
570
  | Leaving the default classpath scan on in a large app | Use `withArtifactsFrom(...)` or `artifact-packages` to narrow it (`ChronicleOptions.kt:58`) |
493
571
  | Expecting WebFlux support from the starter | The web auto-configuration is `SERVLET`-only (`ChronicleWebAutoConfiguration.kt:29`) |
494
572
  | Exiting `main` after an append | Reactors and reducers stop with the process; the stream is live |
573
+ | Passing a `KProperty1` as `@VariantOf`'s `key` | The annotation form always takes a plain string; only the declarative `variantOf(...)` builder accepts a property reference |
574
+ | A `@GlobalFor` mapping targeting a property one variant lacks | `GlobalHandlerPropertyNotOnVariant` at registration, not a silently skipped mapping |
495
575
 
496
576
  ## Verify
497
577
 
@@ -505,5 +585,7 @@ only; there is no in-process reactor, projection, or constraint scenario
505
585
  class.
506
586
  - `awaitRegistration()` is followed by a check that registration actually
507
587
  succeeded, not treated as proof on its own.
588
+ - Every variant group has at least one `@EntersOn`/`entersOn(...)` per variant,
589
+ and every `@GlobalFor` member exists on every variant it targets.
508
590
  - The build is clean and the specifications pass against the verified artifact
509
591
  version.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: cratis-chronicle-client-typescript
3
- description: Talk to a Chronicle server from a Node.js or TypeScript application with @cratis/chronicle - reflect-metadata and decorator compiler settings, ChronicleClient and connection strings, @eventType classes, eventLog.append, reactors and reducers dispatched by camelCase method name, model-bound and declarative projections, glob-based artifact discovery, and the lazy connect and keepalive lifecycle. Use when a Node application appends to or observes a Chronicle event store. Do not use for the .NET, Kotlin, or Elixir clients, and do not use for Arc React frontends or generated Arc proxies.
3
+ description: Talk to a Chronicle server from a Node.js or TypeScript application with @cratis/chronicle - reflect-metadata and decorator compiler settings, ChronicleClient and connection strings, @eventType classes, eventLog.append, reactors and reducers dispatched by camelCase method name, model-bound and declarative projections, variants (@variantOf/@entersOn/@globalFor or .variantOf()/.entersOn()) for an entity with mutually exclusive lifecycle shapes, glob-based artifact discovery, and the lazy connect and keepalive lifecycle. Use when a Node application appends to or observes a Chronicle event store. Do not use for the .NET, Kotlin, or Elixir clients, and do not use for Arc React frontends or generated Arc proxies.
4
4
  license: MIT
5
5
  ---
6
6
 
@@ -317,6 +317,70 @@ Constraints are `@constraint()` on a class implementing `IConstraint` with a
317
317
  model-bound constraint decorators in this client** — the class-plus-builder form
318
318
  is the only one.
319
319
 
320
+ ### Variants — mutually exclusive read models for one entity's lifecycle
321
+
322
+ > Requires `@cratis/chronicle` `6.2.0` or later — newer than this skill's
323
+ > `5.1.0` baseline (`Source/projections/VariantReclassifier.ts` and siblings).
324
+ > Reverify before claiming support; take the version from npm.
325
+
326
+ Some entities do not have one shape for their whole lifetime — a work item is a
327
+ backlog entry until a pull request exists for it, then it is a pull request
328
+ until it merges. **Model-bound** — `@variantOf(identity, key)` and
329
+ `@entersOn(eventType, key?)`, alongside the ordinary `@fromEvent`/`@setFrom`
330
+ decorators:
331
+
332
+ ```typescript
333
+ class WorkItem {} // anchors the group; not itself a read model
334
+
335
+ @variantOf(WorkItem, 'id')
336
+ @entersOn(IssueCreated)
337
+ @fromEvent(IssueCreated)
338
+ @readModel()
339
+ class BacklogItem {
340
+ id = '';
341
+ @setFrom(IssueCreated, 'title') title = '';
342
+ }
343
+
344
+ @variantOf(WorkItem, 'id')
345
+ @entersOn(PullRequestCreated)
346
+ @fromEvent(PullRequestCreated)
347
+ @fromEvent(BuildCompleted) // not the entering event -> update-only join
348
+ @readModel()
349
+ class PullRequestItem {
350
+ id = '';
351
+ @setFrom(PullRequestCreated, 'pullRequestUrl') pullRequestUrl = '';
352
+ @setFrom(BuildCompleted, 'buildStatus') buildStatus = '';
353
+ }
354
+ ```
355
+
356
+ `entersOn` is repeatable (a variant may enter on more than one event) and its
357
+ `key` argument names an *event* property, defaulting to the event source id. A
358
+ mapping shared by every variant of an identity goes on a class decorated
359
+ `@globalFor(identity)` instead of being repeated on each variant — every
360
+ variant it targets must actually declare the property it maps, or
361
+ `GlobalHandlerPropertyNotOnVariant` is thrown when the group is built. A class
362
+ carrying only `@globalFor` is never itself registered as a projection.
363
+
364
+ **Declarative** — `variantOf` and `entersOn` are members of
365
+ `IProjectionBuilderFor<TReadModel>` itself:
366
+
367
+ ```typescript
368
+ @projection()
369
+ class PullRequestItemProjection implements IProjectionFor<PullRequestItem> {
370
+ define(builder: IProjectionBuilderFor<PullRequestItem>): void {
371
+ builder
372
+ .variantOf(WorkItem, m => m.id)
373
+ .entersOn(PullRequestCreated)
374
+ .from(BuildCompleted); // update-only, same reason
375
+ }
376
+ }
377
+ ```
378
+
379
+ **A variant that declares no `@entersOn`/`entersOn(...)` throws
380
+ `VariantMustDeclareEntersOnEvent`** when the group is built — a variant that
381
+ could never be entered could never be written to at all, since every other
382
+ handler on it is update-only.
383
+
320
384
  ## Discovery is a runtime file glob — this is the biggest difference
321
385
 
322
386
  `ChronicleOptions.discoveryPatterns` defaults to
@@ -382,6 +446,8 @@ not work**. Use module-scope collaborators, as the shipped sample does.
382
446
  | Constructor-injecting a dependency into a reactor | There is no DI; the client constructs it |
383
447
  | Carrying a `3.x` example forward | Tags, the reducer context parameter, and two RPC names changed in `4.0.0` |
384
448
  | Reading `result.sequenceNumber` as a number | It is a `bigint` behind `.value` |
449
+ | A `@globalFor` mapping targeting a property one variant lacks | `GlobalHandlerPropertyNotOnVariant` when the group is built, not a silently skipped mapping |
450
+ | Expecting a `@globalFor`-only class to appear as a projection | It is never registered on its own; it is merged into its variants |
385
451
 
386
452
  ## Verify
387
453
 
@@ -397,4 +463,6 @@ not work**. Use module-scope collaborators, as the shipped sample does.
397
463
  class, and an appended event demonstrably reaches it.
398
464
  - A production connection string carries credentials and `skipTlsValidation=false`.
399
465
  - `client.dispose()` runs on shutdown.
466
+ - Every variant group has at least one `@entersOn`/`entersOn(...)` per variant,
467
+ and every `@globalFor` member exists on every variant it targets.
400
468
  - Lint, `tsc`, and the test suite are clean against the verified package version.
@@ -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 four members
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
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/pi",
3
- "version": "2.26.0",
3
+ "version": "2.28.0",
4
4
  "description": "Configuration-aware Cratis AI integration for Pi",
5
5
  "type": "module",
6
6
  "files": [