@cratis/pi 7.56.3 → 7.57.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/harnesses/copilot/instructions/elixir.instructions.md +2 -2
- package/package/corpus/product-sync.json +7 -5
- package/package/corpus/rules/elixir.md +2 -2
- package/package/corpus/skills/cratis-chronicle-client-elixir/SKILL.md +64 -87
- package/package/corpus/skills/cratis-chronicle-client-elixir/references/append-api.md +10 -0
- package/package/corpus/skills/cratis-chronicle-client-elixir/references/captures.md +43 -0
- package/package/corpus/skills/cratis-chronicle-client-elixir/references/client-options.md +6 -0
- package/package/corpus/skills/cratis-chronicle-client-elixir/references/connections.md +1 -1
- package/package/corpus/skills/cratis-chronicle-client-elixir/references/constraints.md +63 -0
- package/package/corpus/skills/cratis-chronicle-client-elixir/references/decision-reads.md +65 -0
- package/package/corpus/skills/cratis-chronicle-client-elixir/references/discovery-and-generations.md +56 -0
- package/package/corpus/skills/cratis-chronicle-client-elixir/references/event-sources.md +1 -1
- package/package/corpus/skills/cratis-chronicle-client-elixir/references/idioms-and-inspection.md +2 -2
- package/package/corpus/skills/cratis-chronicle-client-elixir/references/observability.md +37 -0
- package/package/corpus/skills/cratis-chronicle-client-elixir/references/patterns.md +22 -0
- package/package/corpus/skills/cratis-chronicle-client-elixir/references/pitfalls.md +9 -1
- package/package/corpus/skills/cratis-chronicle-client-elixir/references/projections.md +28 -0
- package/package/corpus/skills/cratis-chronicle-client-elixir/references/read-models.md +41 -0
- package/package/corpus/skills/cratis-chronicle-client-elixir/references/testing.md +63 -0
- package/package/corpus/skills/cratis-chronicle-client-elixir/references/upgrading.md +18 -0
- package/package/corpus/skills/cratis-chronicle-client-elixir/verification.json +17 -4
- package/package.json +1 -1
|
@@ -12,13 +12,13 @@ paths:
|
|
|
12
12
|
# Elixir Conventions
|
|
13
13
|
|
|
14
14
|
Use Elixir and OTP idioms, not translated C# or JVM shapes. These conventions
|
|
15
|
-
are grounded in `Cratis/Chronicle.Elixir v3.
|
|
15
|
+
are grounded in `Cratis/Chronicle.Elixir v3.13.0` (`Source/chronicle/mix.exs`,
|
|
16
16
|
`.formatter.exs`, and `lib/chronicle/`). Client-specific APIs belong in the
|
|
17
17
|
**cratis-chronicle-client-elixir** skill.
|
|
18
18
|
|
|
19
19
|
## Build and format
|
|
20
20
|
|
|
21
|
-
- For Chronicle 3.
|
|
21
|
+
- For Chronicle 3.13.x use Elixir 1.18 or later (`~> 1.18`).
|
|
22
22
|
- Run `mix compile --warnings-as-errors`, `mix test`, and
|
|
23
23
|
`mix format --check-formatted` with the project's formatter inputs.
|
|
24
24
|
- Use two-space indentation and let `mix format` own layout.
|
|
@@ -579,17 +579,19 @@
|
|
|
579
579
|
}
|
|
580
580
|
],
|
|
581
581
|
"synced": {
|
|
582
|
-
"version": "3.
|
|
583
|
-
"date": "2026-10-
|
|
584
|
-
"by": "Cratis/AI#
|
|
582
|
+
"version": "3.13.0",
|
|
583
|
+
"date": "2026-10-15",
|
|
584
|
+
"by": "Cratis/AI#719"
|
|
585
585
|
},
|
|
586
586
|
"covers": {
|
|
587
587
|
"skills": [
|
|
588
588
|
"cratis-chronicle-client-elixir"
|
|
589
589
|
],
|
|
590
|
-
"rules": [
|
|
590
|
+
"rules": [
|
|
591
|
+
"elixir.md"
|
|
592
|
+
]
|
|
591
593
|
},
|
|
592
|
-
"notes": "
|
|
594
|
+
"notes": "Synced to the v3.13.0 tag: decision reads, Chronicle.Testing, captures, discovery drift, standalone constraints, children collections, concurrency scopes and observability."
|
|
593
595
|
},
|
|
594
596
|
{
|
|
595
597
|
"id": "authproxy",
|
|
@@ -11,13 +11,13 @@ paths:
|
|
|
11
11
|
# Elixir Conventions
|
|
12
12
|
|
|
13
13
|
Use Elixir and OTP idioms, not translated C# or JVM shapes. These conventions
|
|
14
|
-
are grounded in `Cratis/Chronicle.Elixir v3.
|
|
14
|
+
are grounded in `Cratis/Chronicle.Elixir v3.13.0` (`Source/chronicle/mix.exs`,
|
|
15
15
|
`.formatter.exs`, and `lib/chronicle/`). Client-specific APIs belong in the
|
|
16
16
|
**cratis-chronicle-client-elixir** skill.
|
|
17
17
|
|
|
18
18
|
## Build and format
|
|
19
19
|
|
|
20
|
-
- For Chronicle 3.
|
|
20
|
+
- For Chronicle 3.13.x use Elixir 1.18 or later (`~> 1.18`).
|
|
21
21
|
- Run `mix compile --warnings-as-errors`, `mix test`, and
|
|
22
22
|
`mix format --check-formatted` with the project's formatter inputs.
|
|
23
23
|
- Use two-space indentation and let `mix format` own layout.
|
|
@@ -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 - Chronicle.Client supervision and connection lifecycle, EventType structs, append errors, event sources and streams, Chronicle.Concept, PII and encrypted secrets, reactors, read models/projections with composite keys, variants and GlobalHandler, and observer inspection. 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 - Chronicle.Client supervision and connection lifecycle, EventType structs, append errors, event sources and streams, Chronicle.Concept, PII and encrypted secrets, reactors, read models/projections with composite keys, children, variants and GlobalHandler, decision reads, in-memory testing, captures, constraints, discovery drift and observer inspection. 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
|
|
|
@@ -17,23 +17,11 @@ to yours, and everything after that is ordinary OTP.
|
|
|
17
17
|
|
|
18
18
|
Source: `Cratis/Chronicle.Elixir` (Hex package `cratis_chronicle`). Source paths below are relative to `Source/chronicle/`.
|
|
19
19
|
|
|
20
|
-
Read [references/upgrading.md](references/upgrading.md) when moving an application from an older client
|
|
20
|
+
Read [references/upgrading.md](references/upgrading.md) when moving an application from an older client. Requires Elixir `~> 1.18`; read [references/product-sources.md](references/product-sources.md) for the package and contracts dependency facts.
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
> **A git tag is not a Hex release.** `mix deps.get` fetches only published versions, and the repository's `VERSION` file is a build-time placeholder: never quote it. Use namespaced modules (`Chronicle.Events.EventType`, not `Chronicle.EventType`); older READMEs got this wrong. Maintained snippets live in `Documentation/client-snippets/`.
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
> (`use Chronicle.Concept, type: :string`, a single-value concept that carries its
|
|
26
|
-
> `pii` classification once, mirroring `ConceptAs<T>`) is the preferred way to
|
|
27
|
-
> classify a personal value; a client without it annotates fields with `pii/1,2`.
|
|
28
|
-
|
|
29
|
-
Requires `cratis_chronicle` 3.0.0 for `Chronicle.Concept`.
|
|
30
|
-
|
|
31
|
-
> **The repository's `VERSION` file is a build-time placeholder** overwritten by the publish workflow. Never quote it.
|
|
32
|
-
|
|
33
|
-
> Use namespaced modules (`Chronicle.Events.EventType`, not `Chronicle.EventType`); older READMEs got this wrong. Maintained
|
|
34
|
-
> snippets live in `Documentation/client-snippets/`.
|
|
35
|
-
|
|
36
|
-
Select the published version from hex.pm rather than treating a guide's range as a pin.
|
|
24
|
+
`Chronicle.Concept` (`use Chronicle.Concept, type: :string`) is the preferred way to classify a personal value once, mirroring `ConceptAs<T>`; it requires `cratis_chronicle` 3.0.0, and an older client annotates fields with `pii/1,2`.
|
|
37
25
|
|
|
38
26
|
## Adding it
|
|
39
27
|
|
|
@@ -41,7 +29,7 @@ Select the published version from hex.pm rather than treating a guide's range as
|
|
|
41
29
|
# mix.exs
|
|
42
30
|
defp deps do
|
|
43
31
|
[
|
|
44
|
-
{:cratis_chronicle, "~> 3.
|
|
32
|
+
{:cratis_chronicle, "~> 3.13"}
|
|
45
33
|
]
|
|
46
34
|
end
|
|
47
35
|
```
|
|
@@ -104,17 +92,14 @@ case Lifecycle.wait_until(Lifecycle.name_for(Chronicle.Client), :registered, 30_
|
|
|
104
92
|
end
|
|
105
93
|
```
|
|
106
94
|
|
|
107
|
-
`wait_until(lifecycle, target_phase, timeout \\ 30_000)` is the blocking wait
|
|
108
|
-
`subscribe/1` returns `{:ok, phase, connection_id}` **and** sends it as a message
|
|
109
|
-
|
|
110
|
-
read.
|
|
95
|
+
`wait_until(lifecycle, target_phase, timeout \\ 30_000)` is the blocking wait;
|
|
96
|
+
`subscribe/1` returns `{:ok, phase, connection_id}` **and** sends it as a message
|
|
97
|
+
(closing the subscribe-after-transition race); `phase/1` is the plain read.
|
|
111
98
|
|
|
112
|
-
Reconnect never gives up
|
|
113
|
-
|
|
114
|
-
Registration failures leave the client in `:connected`, log
|
|
99
|
+
Reconnect never gives up (backoff 1000 to 10000 ms; `:retry_attempts` is
|
|
100
|
+
ignored). Registration failures leave the client in `:connected`, log
|
|
115
101
|
`Chronicle registration failed`, and retry every five seconds. Appends before a
|
|
116
|
-
channel exists return `{:error, :not_connected}
|
|
117
|
-
30 seconds for `:registered`.
|
|
102
|
+
channel exists return `{:error, :not_connected}`.
|
|
118
103
|
|
|
119
104
|
## Authentication and TLS
|
|
120
105
|
|
|
@@ -154,24 +139,23 @@ defaults: `nil` registers as string, numbers as number, booleans as boolean;
|
|
|
154
139
|
list element schemas use the first default element. Nested structs and concepts
|
|
155
140
|
are descended into (`schemas/json_schema_generator.ex`).
|
|
156
141
|
|
|
157
|
-
The macro imports
|
|
158
|
-
`
|
|
159
|
-
`
|
|
160
|
-
`
|
|
161
|
-
|
|
162
|
-
`@remove_constraint` also work.
|
|
142
|
+
The macro imports `unique/1,2`, `unique_event_type/0,1`, `remove_constraint/1,2`,
|
|
143
|
+
`closes_stream/1`, `Chronicle.Compliance.pii/1,2` and
|
|
144
|
+
`Chronicle.Confidentiality.encrypted/1,2,3`; the accumulating attributes
|
|
145
|
+
`@unique`, `@unique_event_type`, `@remove_constraint` also work. Read
|
|
146
|
+
[references/constraints.md](references/constraints.md) for their options.
|
|
163
147
|
|
|
164
148
|
**Only top-level event keys are camelCased.** `encode_event/1` transforms them
|
|
165
149
|
before `Jason.encode!` (`event_sequences/event_log.ex`); nested keys are
|
|
166
150
|
not transformed. Event structs get no `Jason.Encoder` implementation;
|
|
167
|
-
`Chronicle.Concept` does, encoding its wrapped value
|
|
151
|
+
`Chronicle.Concept` does, encoding its wrapped value.
|
|
168
152
|
|
|
169
153
|
Read [references/compliance.md](references/compliance.md) when defining personal data, data subjects, concepts or encrypted operational secrets. `pii` and `encrypted` are not interchangeable and cannot mark the same leaf.
|
|
170
154
|
|
|
171
155
|
Schema evolution is
|
|
172
156
|
`use Chronicle.Events.Migration, from: {Mod, generation: n}, to: {Mod, generation: n + 1}`
|
|
173
|
-
with `upcast/1` and `downcast/1`; the generations must be exactly one apart
|
|
174
|
-
|
|
157
|
+
with `upcast/1` and `downcast/1`; the generations must be exactly one apart. Mark
|
|
158
|
+
the older module with `generation_for:`, not a second `:id`.
|
|
175
159
|
|
|
176
160
|
## Appending
|
|
177
161
|
|
|
@@ -183,40 +167,30 @@ case Chronicle.append("account-1", %MyApp.Events.FundsDeposited{amount: 50}) do
|
|
|
183
167
|
end
|
|
184
168
|
```
|
|
185
169
|
|
|
186
|
-
`Chronicle.append/3` delegates to `Chronicle.EventSequences.EventLog
|
|
187
|
-
(`lib/chronicle.ex`):
|
|
188
|
-
|
|
189
|
-
```elixir
|
|
190
|
-
@spec append(String.t(), struct(), keyword()) :: :ok | {:error, term()}
|
|
191
|
-
```
|
|
192
|
-
|
|
170
|
+
`Chronicle.append/3` delegates to `Chronicle.EventSequences.EventLog`.
|
|
193
171
|
**The event source id comes first, the event second.** The return is a bare
|
|
194
|
-
`:ok`,
|
|
195
|
-
|
|
196
|
-
the outcome.
|
|
172
|
+
`:ok`, carrying no sequence number; use `append_with_result/3` or
|
|
173
|
+
`append_and_wait_for_completion/3` when you need the outcome.
|
|
197
174
|
|
|
198
175
|
Read [references/append-api.md](references/append-api.md) when you need an append variant, a read-back function, or an append option such as `:event_stream_type`, `:concurrency_scope` or `:tags`.
|
|
199
176
|
|
|
200
177
|
Requires `cratis_chronicle` 3.2.0 for rejected appends to return `{:error, reason}`; an older client could report a rejection as `:ok`.
|
|
201
178
|
|
|
202
|
-
Failed appends return `{:error, reason}
|
|
179
|
+
Failed appends return `{:error, reason}`: `{:constraint_violations, violations}`,
|
|
203
180
|
`{:concurrency_violations, violations}`, `{:append_errors, errors}`,
|
|
204
|
-
`:not_connected`, and `{:incompatible_server, response}`
|
|
205
|
-
compatibility preflight per channel. Pair the kernel and
|
|
206
|
-
do not blindly retry a timed-out write.
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
a constraint violation, which is a fine choice in a script and the wrong one in a
|
|
210
|
-
GenServer.
|
|
181
|
+
`{:unknown_event_type, module}`, `:not_connected`, and `{:incompatible_server, response}`
|
|
182
|
+
from the first append's compatibility preflight per channel. Pair the kernel and
|
|
183
|
+
installed contracts; do not blindly retry a timed-out write. **Match on them**: a
|
|
184
|
+
bare `:ok = Chronicle.append(...)` raises a `MatchError` on a violation, fine in a
|
|
185
|
+
script and wrong in a GenServer.
|
|
211
186
|
|
|
212
187
|
Read [references/event-sources.md](references/event-sources.md) when declaring named event sources/streams, deriving concurrency scopes or routing mixed-source batches; definitions have client and kernel requirements stated there.
|
|
213
188
|
|
|
214
|
-
A unit of work is a process: `Chronicle.begin_unit_of_work/1` starts an `Agent
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
`
|
|
218
|
-
|
|
219
|
-
failure. Source/stream options apply to buffered appends too.
|
|
189
|
+
A unit of work is a process: `Chronicle.begin_unit_of_work/1` starts an `Agent`;
|
|
190
|
+
while one is current for the calling process, `append`/`append_many` buffer until
|
|
191
|
+
`UnitOfWork.commit/1` (`:ok | {:error, reason}`) or `rollback/1`.
|
|
192
|
+
`Chronicle.with_unit_of_work(fun, opts)` commits on return and raises on commit
|
|
193
|
+
failure.
|
|
220
194
|
|
|
221
195
|
## Observing
|
|
222
196
|
|
|
@@ -224,11 +198,8 @@ failure. Source/stream options apply to buffered appends too.
|
|
|
224
198
|
|
|
225
199
|
Read [references/reactor-example.md](references/reactor-example.md) when you need a worked reactor module.
|
|
226
200
|
|
|
227
|
-
This follows `Documentation/client-snippets/get-started/common/reactor.md`.
|
|
228
|
-
|
|
229
201
|
- **`@handles` is an accumulating module attribute** declaring the subscription;
|
|
230
|
-
dispatch is then ordinary Elixir pattern matching in `handle/2`.
|
|
231
|
-
Elixir answer to the other clients' method-name or parameter-type conventions.
|
|
202
|
+
dispatch is then ordinary Elixir pattern matching in `handle/2`.
|
|
232
203
|
- The callback is
|
|
233
204
|
`handle(event :: struct(), context :: map()) :: :ok | {:error, term()} | {:ok, struct() | [struct()]}`.
|
|
234
205
|
- **Returning `{:ok, event_or_events}` appends those events as a side effect** —
|
|
@@ -239,28 +210,27 @@ This follows `Documentation/client-snippets/get-started/common/reactor.md`.
|
|
|
239
210
|
- Optional replay callbacks: `on_replay_begin/0`, `on_replay_end/0`,
|
|
240
211
|
`on_partition_replay_begin/1`, `on_partition_replay_end/1`.
|
|
241
212
|
|
|
242
|
-
**The
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
source name or `nil`
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
213
|
+
**The context is a `Chronicle.Events.EventContext` struct** (requires 3.11.0; a
|
|
214
|
+
bare map before). Pattern match on keys or use `context.caused_by`. It carries
|
|
215
|
+
`event_source_id`, `sequence_number`, `occurred`, `event_source` (the registered
|
|
216
|
+
source name or `nil`), `event_store`, `namespace`, `correlation_id`, `caused_by`,
|
|
217
|
+
`causation`, `tags`, `subject`, `appended_generation`, `replaying?` and, for
|
|
218
|
+
reactors only, `delivery_id` (stable across replay and failed-partition
|
|
219
|
+
recovery; key a receipt on it). Reducers get the same struct with
|
|
220
|
+
`observation_state` set (`lib/chronicle/events/event_context.ex`).
|
|
249
221
|
|
|
250
222
|
### Read models and projections
|
|
251
223
|
|
|
252
|
-
Read [references/reactor-example.md](references/reactor-example.md#read-model-example) for a worked model-bound read model.
|
|
253
|
-
|
|
254
|
-
This is the model-bound shape; use typed defaults for its registered schema.
|
|
224
|
+
Read [references/reactor-example.md](references/reactor-example.md#read-model-example) for a worked model-bound read model with typed defaults.
|
|
255
225
|
|
|
256
|
-
The DSL macros are `from/1
|
|
226
|
+
The DSL macros are `from/1,2`, `join/2`, `removed_with/2`, `from_every/1`, `children/2,3`.
|
|
257
227
|
`from/2` options: `:key` (defaults to `"$eventSourceId"`), `:parent_key`, `:set`,
|
|
258
228
|
`:add`, `:subtract`, `:count`. `join/2` requires `:on`.
|
|
259
229
|
|
|
260
|
-
Read [references/projections.md](references/projections.md) when choosing `passive`, `not_rewindable`, `event_sequence`, `no_auto_map`, composite/event-context keys or
|
|
230
|
+
Read [references/projections.md](references/projections.md) when choosing `passive`, `not_rewindable`, `event_sequence`, `no_auto_map`, composite/event-context keys, dictionary counters or `children/2,3` collections. Snake_case field atoms now map correctly; no camelCase-string workaround is needed.
|
|
261
231
|
|
|
262
232
|
> **The read model id sent to the kernel is the last module segment** —
|
|
263
|
-
> `Module.split() |> List.last()` at `
|
|
233
|
+
> `Module.split() |> List.last()` at `read_models/read_model.ex`.
|
|
264
234
|
> `MyApp.ReadModels.Account` registers as `"Account"`, so two read models with the
|
|
265
235
|
> same final segment collide. Pass `id:` to disambiguate.
|
|
266
236
|
|
|
@@ -283,10 +253,8 @@ Querying (`lib/chronicle/read_models.ex`):
|
|
|
283
253
|
{:ok, account} = Chronicle.read_model(MyApp.ReadModels.AccountBalance, "account-1")
|
|
284
254
|
```
|
|
285
255
|
|
|
286
|
-
`Chronicle.read_model/3`
|
|
287
|
-
|
|
288
|
-
`Chronicle.all/2` delegates to `ReadModels.all/2`
|
|
289
|
-
(`lib/chronicle.ex`, `read_models.ex`).
|
|
256
|
+
`Chronicle.read_model/3` returns `{:ok, struct() | nil} | {:error, term()}`; `nil`
|
|
257
|
+
means absent **or not projected yet**.
|
|
290
258
|
|
|
291
259
|
Use `Chronicle.ReadModels.query(MyApp.ReadModels.AccountBalance, page: 0)` for paging:
|
|
292
260
|
`:page` is zero-based (default `0`), `:page_size` defaults to `50`, and
|
|
@@ -294,18 +262,26 @@ Use `Chronicle.ReadModels.query(MyApp.ReadModels.AccountBalance, page: 0)` for p
|
|
|
294
262
|
`occurrences/2`, and `definitions/1` expose history, replay containers, and
|
|
295
263
|
registered definitions.
|
|
296
264
|
|
|
297
|
-
**Live updates arrive as messages, not as a stream.** `ReadModels.watch/2`
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
265
|
+
**Live updates arrive as messages, not as a stream.** `ReadModels.watch/2` and `subscribe/2` send `{:chronicle_read_model_changed, module, %Changeset{}}` and error messages to the calling process, which needs a `handle_info` for each. Read [references/read-models.md](references/read-models.md) when subscribing, filtering, observing collections, previewing or waiting for a projection.
|
|
266
|
+
|
|
267
|
+
## Guarding decisions, testing and more
|
|
268
|
+
|
|
269
|
+
Read the reference that matches the task:
|
|
270
|
+
|
|
271
|
+
- [references/decision-reads.md](references/decision-reads.md) when an append depends on state a concurrent append could change: `Chronicle.decide/2`, `DecisionReads`, `ConcurrencyScope` builders, the optimistic default.
|
|
272
|
+
- [references/testing.md](references/testing.md) when writing specs: `Chronicle.Testing.Case`, assertions, the in-memory kernel's limits.
|
|
273
|
+
- [references/constraints.md](references/constraints.md) when declaring uniqueness, standalone constraint modules, `remove_constraint/2`, violation details or closing events.
|
|
274
|
+
- [references/discovery-and-generations.md](references/discovery-and-generations.md) when pinning ids, setting `:drift_policy`, running `mix chronicle.discover`, or handling older event generations.
|
|
275
|
+
- [references/captures.md](references/captures.md) when turning external API, webhook or other-store data into events.
|
|
276
|
+
- [references/observability.md](references/observability.md) and [references/patterns.md](references/patterns.md) for OpenTelemetry/`:telemetry` and `Chronicle.Patterns`.
|
|
301
277
|
|
|
302
278
|
## Idioms and operational inspection
|
|
303
279
|
|
|
304
|
-
Read [references/idioms-and-inspection.md](references/idioms-and-inspection.md) when carrying correlation or identity across a `Task`, formatting the DSL, supervising
|
|
280
|
+
Read [references/idioms-and-inspection.md](references/idioms-and-inspection.md) when carrying correlation or identity across a `Task`, formatting the DSL, supervising watches, or removing observers (obtain explicit authority first).
|
|
305
281
|
|
|
306
282
|
## Common pitfalls
|
|
307
283
|
|
|
308
|
-
Read [references/pitfalls.md](references/pitfalls.md) when troubleshooting discovery, defaults, process context,
|
|
284
|
+
Read [references/pitfalls.md](references/pitfalls.md) when troubleshooting discovery, defaults, process context, variants or older-version examples.
|
|
309
285
|
|
|
310
286
|
## Verify
|
|
311
287
|
|
|
@@ -319,10 +295,11 @@ Read [references/pitfalls.md](references/pitfalls.md) when troubleshooting disco
|
|
|
319
295
|
- Every event type module passes an explicit, stable `id:`.
|
|
320
296
|
- Every `Chronicle.append/3` call site handles `{:error, _}` as well as `:ok`.
|
|
321
297
|
- Every reactor's `@handles` list matches the clauses of its `handle/2`.
|
|
298
|
+
- Reactors, reducers and projections that must survive a rename pin their `:id`.
|
|
322
299
|
- Read model module names have distinct final segments, or explicit ids.
|
|
323
300
|
- Every variant group has at least one `enters_on` per variant, and every
|
|
324
301
|
`GlobalHandler` member exists on every variant it targets.
|
|
325
302
|
- Production logging filters credential-bearing gRPC/Mint crash reports; see
|
|
326
303
|
the connections reference for limits.
|
|
327
304
|
- Examples match the APIs and schema defaults at the installed tag.
|
|
328
|
-
- `mix compile --warnings-as-errors` and `mix test` are clean.
|
|
305
|
+
- `mix compile --warnings-as-errors` and `mix test` are clean; `mix chronicle.discover --otp-app my_app` lists every expected artifact.
|
|
@@ -38,3 +38,13 @@ Read [event-sources.md](event-sources.md) for definitions, stream routing and de
|
|
|
38
38
|
`append_response.ex` returns `{:error, {:constraint_violations, violations}}`, `{:error, {:concurrency_violations, violations}}` or `{:error, {:append_errors, errors}}` on rejection. Channel/preflight errors include `{:error, :not_connected}` and `{:error, {:incompatible_server, response}}`. A transport timeout does not prove that no event was stored.
|
|
39
39
|
|
|
40
40
|
`append_and_wait_for_completion/3` has **no many-event variant**. `{:ok, result}` does not imply every observer succeeded: inspect `result.success` and `failed_partitions`. Its wait happens **after** the append (`event_log.ex`), so `{:error, reason}` can follow a stored event if waiting fails. Do not retry the write blindly; reconcile the stored outcome first.
|
|
41
|
+
|
|
42
|
+
## Structured results, prepared events and unknown types
|
|
43
|
+
|
|
44
|
+
Requires `cratis_chronicle` 3.13.0 for everything in this section.
|
|
45
|
+
|
|
46
|
+
- `append_with_result/3` and `append_many_with_result/3` return `{:ok, %AppendResult{sequence_number: _}}` (batch: every assigned number, in order) or `{:error, %AppendResult{}}` carrying `:constraint_violations` (each with `:constraint_name`, `:message`, `:event_type_id`, `:sequence_number`, `:details`), `:concurrency_violations` (`:event_source_id`, `:expected_sequence_number`, `:actual_sequence_number`) and `:errors`. Use them when you need the assigned sequence number or the structured reason; transport failures stay plain `{:error, reason}`. `AppendResult.wait_for_completion/2` waits for observers.
|
|
47
|
+
- `EventLog.prepare/2` serializes an event once into a `PreparedEvent` bound to the client, namespace and event sequence that prepared it; reuse it with `EventLog.append_prepared/3` and `Streams.verify_content/3`. Appending it through another client, namespace or sequence returns `{:error, :prepared_event_belongs_to_another_sequence}`. Single use is not enforced.
|
|
48
|
+
- **A struct that does not `use Chronicle.Events.EventType` returns `{:error, {:unknown_event_type, module}}`** from every append path, `prepare/2` and `Streams.verify_content/3`; it does not raise. Match it like any other error.
|
|
49
|
+
- A payload over 4 MiB returns `{:error, {:payload_too_large, size, limit}}` before sending. Raise or disable the guard with `config :cratis_chronicle, max_payload_bytes: ...` (or `:infinity`) only when the kernel is configured for larger messages.
|
|
50
|
+
- Reads: `get_for_event_source/3` and `get_from_sequence_number/2` return raw wire events; pass `decode: true` for `Chronicle.Events.AppendedEvent` structs (`event: nil` and `:raw` for an unknown type, `redacted?: true` for a redacted event).
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<!-- Copyright (c) Cratis. All rights reserved. -->
|
|
2
|
+
<!-- Licensed under the MIT license. See LICENSE file in the project root for full license information. -->
|
|
3
|
+
|
|
4
|
+
# Captures
|
|
5
|
+
|
|
6
|
+
Source: `Cratis/Chronicle.Elixir`: `Source/chronicle/lib/chronicle/captures.ex`, `captures/capture.ex`, `captures/registrar.ex`, and `Documentation/captures.md`.
|
|
7
|
+
|
|
8
|
+
Requires `cratis_chronicle` 3.13.0.
|
|
9
|
+
|
|
10
|
+
A capture turns data that arrives from outside the event log (an external API, an inbound webhook, another event store's public events) into events appended to this event store. The DSL compiles at build time to Capture Declaration Language (CDL), the text the kernel's `SaveCapture` accepts. Use a capture instead of a hand-written poller when the source data should become facts you own.
|
|
11
|
+
|
|
12
|
+
```elixir
|
|
13
|
+
# Copyright (c) Cratis. All rights reserved.
|
|
14
|
+
# Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
15
|
+
|
|
16
|
+
defmodule MyApp.Captures.InvoiceCapture do
|
|
17
|
+
use Chronicle.Captures.Capture, name: "InvoiceCapture"
|
|
18
|
+
|
|
19
|
+
api_source("InvoicingApi", route: "/invoices", poll: "10m")
|
|
20
|
+
|
|
21
|
+
key("id")
|
|
22
|
+
|
|
23
|
+
append MyApp.Events.InvoiceStatusChanged, when: "status" do
|
|
24
|
+
set(:status, "$.status")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- Exactly one source: `api_source/1,2` (polls a named external service), `webhook_source/1`, or `events_source/1` (a list of registered event type modules from another event store's public events).
|
|
30
|
+
- `key/1` names the property path that identifies and diffs an item; use `"$eventSourceId"` for an events source.
|
|
31
|
+
- `:id` defaults to a UUIDv5 of the module name, so redeploying updates the same kernel-side capture. `:name` defaults to the last module segment. `start: true` makes the registrar start it once saved.
|
|
32
|
+
- `when:` takes a property name, `{:any, [...]}`, `{:all, [...]}`, `{:transition, property, from, to}`, `:added`, `:removed`, or `{:expression, "template"}`. `set/2` maps a target field from `"$.path"` (payload) or `"$context.<property>"`.
|
|
33
|
+
- The DSL also has `nested` and `children` scopes, and a `map do` block with `rename`, `template`, `translate` and `split`.
|
|
34
|
+
|
|
35
|
+
**An appended event type's id must start with an uppercase letter and contain only letters, digits and underscores.** The kernel's CDL parser rejects hyphens, so `use Chronicle.Events.EventType, id: "InvoiceStatusChanged"` works as an `append` target and `id: "invoice-status-changed"` raises `ArgumentError` at compile time. This restriction does not apply to `events_source/1`.
|
|
36
|
+
|
|
37
|
+
**The kernel's capture engine runs a subset of the language**: only `api` and `events` sources, and only root-level `append` rules. `map`, `nested` and `children` compile and validate but are not applied at runtime, and webhook sources cannot carry authentication.
|
|
38
|
+
|
|
39
|
+
## Registering and managing
|
|
40
|
+
|
|
41
|
+
Captures are discovered with `otp_app:` or listed in the client's `:captures` option. `Chronicle.Captures.Registrar` saves them at `:registered` and again on every reconnect. It skips a capture whose declaration already matches, and warns before saving over a **started** capture whose declaration changed, because saving stops it on the kernel.
|
|
42
|
+
|
|
43
|
+
Manage directly with `Chronicle.Captures.register/1`, `start/1`, `stop/1`, `delete/1`, `list/0`, `validate/1` (a module or raw CDL text) and `captured_events/1`. `start`, `stop` and `delete` also accept a raw capture id; `delete` is destructive, so obtain authority for the specific capture first.
|
|
@@ -16,6 +16,12 @@ your app must read its own configuration and pass it in (`lib/chronicle/client.e
|
|
|
16
16
|
| `:discover` | `true` |
|
|
17
17
|
| `:otp_app` | none; falls back to scanning loaded modules |
|
|
18
18
|
| `:event_types`, `:event_sources`, `:migrations`, `:reactors`, `:reducers`, `:read_models`, `:projections`, `:seeders`, `:webhooks`, `:event_store_subscriptions`, `:global_handlers` | `[]`, merged with discovered |
|
|
19
|
+
| `:constraints`, `:captures`, `:read_model_reactors` | `[]`, merged with discovered where discoverable |
|
|
20
|
+
| `:drift_policy` | `:warn`; `:ignore` or `:fail`. See [discovery-and-generations.md](discovery-and-generations.md) |
|
|
21
|
+
| `:strict_validation` | `false`; `true` raises on event type and migration mistakes instead of logging |
|
|
22
|
+
| `:warn_defaulted_ids` | `true` |
|
|
23
|
+
| `:event_generation_delivery` | `:compatibility`; `:pinned` delivers events at their appended generation |
|
|
24
|
+
| `:concurrency` | none; `[default: :optimistic, check_first_append: bool]`. See [decision-reads.md](decision-reads.md) |
|
|
19
25
|
| `:default_sink_type_id` | `:mongodb`; accepts `:mongodb`, `:sql`, `:in_memory`, `:none` or a raw string |
|
|
20
26
|
| `:server_address` | alternative `"host:port"` address (connection option) |
|
|
21
27
|
| `:skip_tls_validation`, `:load_balancer`, `:grpc_options`, `:reconnect_base_delay`, `:reconnect_max_delay` | forwarded to the connection | — |
|
|
@@ -45,7 +45,7 @@ The client uses grpc 1.x and Mint 1.11+, owns its transport, and needs no `GRPC.
|
|
|
45
45
|
|
|
46
46
|
## Credential-safe logging is still necessary
|
|
47
47
|
|
|
48
|
-
Chronicle's own process status redacts credentials, but dependency crash reports can expose gRPC headers, bearer tokens, API keys and Mint HPACK tables.
|
|
48
|
+
Chronicle's own process status redacts credentials, but dependency crash reports can expose gRPC headers, bearer tokens, API keys and Mint HPACK tables. The client itself installs a primary Logger filter (`Chronicle.Connections.ResponseCrashRedaction`) when a connection starts, replacing the Mint response-process termination report, the one shape it can intercept generically, with a one-line notice (requires `cratis_chronicle` 3.13.0). The `ConnectionProcess` and `GRPC.Client.Connection` state can still print credentials, so before starting the client, install the primary Logger filter from the [client crash-report guide](https://github.com/Cratis/Chronicle.Elixir/blob/main/Documentation/connections/crash-reports.md). It matches `Elixir.GRPC.Client`, `Elixir.GRPC.Channel`, and `Elixir.Mint.` report content and replaces reports with a notice.
|
|
49
49
|
|
|
50
50
|
Keep `handle_sasl_reports: false`. This is a safety net, not a guarantee: plain warning strings carrying exit reasons are not covered, and filters do not survive a Logger restart. Restrict production log access and rotate any credential already exposed. Never log connection strings.
|
|
51
51
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<!-- Copyright (c) Cratis. All rights reserved. -->
|
|
2
|
+
<!-- Licensed under the MIT license. See LICENSE file in the project root for full license information. -->
|
|
3
|
+
|
|
4
|
+
# Constraints
|
|
5
|
+
|
|
6
|
+
Source: `Cratis/Chronicle.Elixir`: `Source/chronicle/lib/chronicle/events/event_type.ex`, `events/constraint.ex`, `events/constraint_violation.ex`, and `Documentation/constraints.md`.
|
|
7
|
+
|
|
8
|
+
Requires `cratis_chronicle` 3.13.0 for standalone constraint modules, `remove_constraint/2`, `:scope` and `ConstraintViolation.details/1`.
|
|
9
|
+
|
|
10
|
+
A constraint is checked by the kernel at append time; a violation returns `{:error, {:constraint_violations, violations}}` (or `{:error, %AppendResult{}}` from `append_with_result/3`).
|
|
11
|
+
|
|
12
|
+
## On the event type
|
|
13
|
+
|
|
14
|
+
`unique/2`, `unique_event_type/1`, `remove_constraint/1,2` and `closes_stream/1` are imported by `use Chronicle.Events.EventType`.
|
|
15
|
+
|
|
16
|
+
| Option | Meaning |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| `:name` | Constraint name; defaults to the first field (`unique`) or the event type id (`unique_event_type`) |
|
|
19
|
+
| `:mode` | `:per_event_source` (default) keeps one value per event source and frees the old one when replaced; `:per_value` keeps every value until a removal event releases it |
|
|
20
|
+
| `:ignore_casing` | Case-insensitive comparison (`unique` only) |
|
|
21
|
+
| `:message` | A string or a `{Module, :function}` callback invoked with the violation |
|
|
22
|
+
| `:scope` | `:per_event_source_type`, `:per_event_stream_type`, `:per_event_stream_id`, or a list; narrows uniqueness to values seen along those dimensions. Unscoped is global |
|
|
23
|
+
| `:event_sequences` | Restrict to specific event sequences |
|
|
24
|
+
|
|
25
|
+
An unknown option or invalid `:scope` compiles with a warning naming the module and is ignored; an invalid `:mode` raises `ArgumentError` when the definition is built.
|
|
26
|
+
|
|
27
|
+
## Releasing a held value
|
|
28
|
+
|
|
29
|
+
In `:per_value` mode, `remove_constraint("Name")` releases every value the removal event's event source held. `remove_constraint("Name", properties: [:version_id])` releases only the value the removal event carries, in the order of the constrained properties.
|
|
30
|
+
|
|
31
|
+
## Standalone constraint modules
|
|
32
|
+
|
|
33
|
+
Use `use Chronicle.Events.Constraint` when one name spans several event types or lives apart from any one event type's module.
|
|
34
|
+
|
|
35
|
+
```elixir
|
|
36
|
+
# Copyright (c) Cratis. All rights reserved.
|
|
37
|
+
# Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
38
|
+
|
|
39
|
+
defmodule MyApp.Constraints.UniqueEmail do
|
|
40
|
+
use Chronicle.Events.Constraint
|
|
41
|
+
|
|
42
|
+
unique name: "UniqueEmail" do
|
|
43
|
+
on MyApp.Events.UserRegistered, [:email]
|
|
44
|
+
on MyApp.Events.UserEmailChanged, [:new_email]
|
|
45
|
+
removed_with MyApp.Events.UserRemoved
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- `name:` is **required** on a standalone declaration.
|
|
51
|
+
- `removed_with/1,2` inside `unique/2` releases the value (with `properties:` in `:per_value` mode). After a bare `unique_event_type/1,2` it releases that constraint; calling it before any `unique_event_type` is a compile error.
|
|
52
|
+
- Two `unique_event_type` declarations with the same `:name` merge into one mutually exclusive constraint: at most one event of the named set per event source (`unique_event_type MyApp.Events.PersonErased, name: "PersonTerminal"`).
|
|
53
|
+
- Modules are discovered like event types, or listed in the client's `:constraints` option (and in `start_supervised_client/1` when testing).
|
|
54
|
+
|
|
55
|
+
## Violation details
|
|
56
|
+
|
|
57
|
+
`Chronicle.Events.ConstraintViolation.details(violation)` maps the kernel's detail keys to atoms: `:property_name`, `:property_value`, `:closed_by`, `:closed_at_sequence_number`; any other key stays a string. A PII property's `:property_value` is its **encrypted** form. Use it in a `:message` callback: `"A project named '#{ConstraintViolation.details(violation)[:property_value]}' already exists."`.
|
|
58
|
+
|
|
59
|
+
## Closing events
|
|
60
|
+
|
|
61
|
+
`closes_stream event_stream_id_from: :period, reopened_by: [MyApp.Events.PeriodReopened]` on an event type closes that stream scope when the event is appended; later events in the scope are refused. Options: `:dimensions`, `:event_stream_id_from`, `:reopened_by`, `:name`, `:event_sequences`; same-named declarations merge and must agree on dimensions. Registration closes nothing in history. Inspect closed scopes with `Chronicle.EventSequences.Streams`. The in-memory test kernel does not model `closes_stream`.
|
|
62
|
+
|
|
63
|
+
Constraint drift is not detected: the kernel cannot list registered constraints.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<!-- Copyright (c) Cratis. All rights reserved. -->
|
|
2
|
+
<!-- Licensed under the MIT license. See LICENSE file in the project root for full license information. -->
|
|
3
|
+
|
|
4
|
+
# Decision reads and the dynamic consistency boundary
|
|
5
|
+
|
|
6
|
+
Source: `Cratis/Chronicle.Elixir`: `Source/chronicle/lib/chronicle/read_models/decision_reads.ex`, `decision_read_refused.ex`, `decision_conflict.ex`, `transactions/unit_of_work.ex`, and `Documentation/dynamic-consistency-boundary.md`.
|
|
7
|
+
|
|
8
|
+
Requires `cratis_chronicle` 3.13.0.
|
|
9
|
+
|
|
10
|
+
Use a decision read when an append depends on state that a concurrent append could change (a seat can be booked once, a course has capacity). It folds a read model at a key in a fresh session and remembers the exact log position it stopped at; the commit is then rejected by the kernel if a matching event arrived since. An ordinary `Chronicle.read_model/3` followed by `append/3` is **not** a guard: it can silently act on stale state.
|
|
11
|
+
|
|
12
|
+
## Decide
|
|
13
|
+
|
|
14
|
+
```elixir
|
|
15
|
+
result =
|
|
16
|
+
Chronicle.decide(fn _unit_of_work ->
|
|
17
|
+
{:ok, read} = Chronicle.ReadModels.DecisionReads.get(MyApp.ReadModels.Seat, "seat-5")
|
|
18
|
+
|
|
19
|
+
if read.instance == nil do
|
|
20
|
+
Chronicle.EventSequences.EventLog.append("seat-5", %MyApp.Events.SeatBooked{by: "alice"})
|
|
21
|
+
end
|
|
22
|
+
end)
|
|
23
|
+
|
|
24
|
+
case result do
|
|
25
|
+
{:ok, _} -> :booked
|
|
26
|
+
{:error, %Chronicle.ReadModels.DecisionConflict{}} -> :retry
|
|
27
|
+
{:error, reason} -> {:error, reason}
|
|
28
|
+
end
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- `Chronicle.decide/2` runs the function in a unit of work and commits on return. It returns `{:ok, result}`, or `{:error, %DecisionConflict{}}` when an enrolled decision read conflicted with a concurrent append; nothing from that attempt was written, so re-read and decide again. Other commit failures are plain `{:error, reason}`.
|
|
32
|
+
- Unlike `with_unit_of_work/2`, `decide/2` never raises on a conflict. A concurrency violation without an enrolled decision read stays `{:error, {:concurrency_violations, _}}`.
|
|
33
|
+
- **`DecisionReads.get/2` enrolls the read only inside a unit of work** (`decide/2` or `UnitOfWork.with_unit_of_work/2`). Outside one it returns `{:error, :requires_unit_of_work}`.
|
|
34
|
+
- `DecisionReads.get_detached/2` folds the same way and enrolls nothing; use it for a read you do not intend to guard.
|
|
35
|
+
- A rule across two entities needs one decision read per event source. Enroll both inside the same `decide/2` and append one event to each; the kernel accepts or rejects the whole batch.
|
|
36
|
+
- `get/4` and `get_detached/4` take `(model, event_source_id, event_stream_type, event_stream_id)` to guard one named stream. The default stream (`""` or `"All"`) is refused with `:all_streams_not_supported`.
|
|
37
|
+
|
|
38
|
+
## Admission
|
|
39
|
+
|
|
40
|
+
`DecisionReads.admit/2` and `admit_for_stream/2` check a model's shape without any I/O and return `{:ok, event_types}` or `{:error, reason}`. `get` and `get_detached` **raise** `Chronicle.ReadModels.DecisionReadRefused` for a refused shape, because that is a programming error. Refusal reasons: `:reducer` (an active reducer), `:join`, `:hierarchy` (`children/2`), `:not_event_log`, `:open_ended_event_types` (`from_every/1`), `:no_event_types`, `:not_event_source_keyed`, `:unsupported_event_type_id`, `:all_streams_not_supported`, `:stream_scope_not_supported`, `:fold_incomplete`.
|
|
41
|
+
|
|
42
|
+
A model with `join/2`, `removed_with_join/2`, `children/2` or `from_every/1` cannot be guarded. A **passive** reducer (`use Chronicle.Reducers.Reducer, model: M, active: false`) can back a decision read because it is folded fresh on the client; an active reducer cannot.
|
|
43
|
+
|
|
44
|
+
## Explicit concurrency scopes
|
|
45
|
+
|
|
46
|
+
When a plain guard on the tail is enough, use `Chronicle.Events.ConcurrencyScope` (`:concurrency_scope` append option) instead of a read model.
|
|
47
|
+
|
|
48
|
+
| Need | Builder |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| Guard at the current tail in one call | `{:ok, scope} = ConcurrencyScope.at_tail("account-1", event_stream_type: "Transactions")`; narrowing options: `:event_stream_type`, `:event_stream_id`, `:event_source_type`, `:event_types` |
|
|
51
|
+
| "This can only happen once" | `ConcurrencyScope.expecting_no_matching_event(event_source_id: true, event_types: [MyApp.Events.UserRegistered])` |
|
|
52
|
+
| Build by hand | `12 \|> ConcurrencyScope.new() \|> with_event_source() \|> with_event_stream_type("Transactions") \|> with_event_type(Mod)`; also `with_sequence_number/2`, `with_event_stream_id/2`, `with_event_source_type/2`, `with_event_types/2` |
|
|
53
|
+
| No guard | `ConcurrencyScope.none()` |
|
|
54
|
+
|
|
55
|
+
**Leaving the expected sequence number unset turns the check off.** It does not mean "expect no event"; use `expecting_no_matching_event/1`, which sends the kernel's explicit condition (requires 3.11.0).
|
|
56
|
+
|
|
57
|
+
## Default optimistic strategy
|
|
58
|
+
|
|
59
|
+
```elixir
|
|
60
|
+
{Chronicle.Client, ..., concurrency: [default: :optimistic, check_first_append: false]}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
By default an append with no explicit or definition-derived scope sends no guard. With `default: :optimistic` the client reads the tail, narrowed the way the append is routed, and expects nothing newer. `check_first_append: true` also guards the first append into an empty narrowing with `expecting_no_matching_event/1`. An explicit or definition-derived scope always wins.
|
|
64
|
+
|
|
65
|
+
Two **unequal** explicit scopes for one event source id in one batch or unit of work fail before sending with `{:error, {:duplicate_concurrency_scope, event_source_id}}`; equal scopes collapse to one.
|
package/package/corpus/skills/cratis-chronicle-client-elixir/references/discovery-and-generations.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<!-- Copyright (c) Cratis. All rights reserved. -->
|
|
2
|
+
<!-- Licensed under the MIT license. See LICENSE file in the project root for full license information. -->
|
|
3
|
+
|
|
4
|
+
# Discovery, artifact identity and event generations
|
|
5
|
+
|
|
6
|
+
Source: `Cratis/Chronicle.Elixir`: `Source/chronicle/lib/chronicle/discovery/`, `artifacts.ex`, `client.ex`, `events/event_type.ex`, `events/event_types.ex`, `reactors/reactor.ex`, `reducers/reducer.ex`, `lib/mix/tasks/chronicle.discover.ex`, and `Documentation/discovery.md`, `events.md`, `reactors.md`, `reducers.md`.
|
|
7
|
+
|
|
8
|
+
Requires `cratis_chronicle` 3.13.0.
|
|
9
|
+
|
|
10
|
+
## The discovery report
|
|
11
|
+
|
|
12
|
+
`Chronicle.Client` builds a `%Chronicle.Discovery.Report{}` at start, logs a one-line summary at `:info` and the whole report at `:debug`. Read it with `Chronicle.Client.discovery_report()` and print it with `Chronicle.Discovery.Report.format/1`. `Chronicle.Artifacts.report(:my_app)` builds the same report without starting a client; `Chronicle.Artifacts.discover/1` still returns its plain map.
|
|
13
|
+
|
|
14
|
+
Run it from the shell with `mix chronicle.discover --otp-app my_app`; add `--connection-string "chronicle://..."` to compare against a kernel (event store `"default"`, namespace `"Default"`) and `--fail-on-drift` to exit `1` on a warning-severity finding. Exit codes: `0` clean, `1` drift found, `2` could not run (no `--otp-app`, or kernel unreachable). `--fail-on-drift` has no effect without `--connection-string`.
|
|
15
|
+
|
|
16
|
+
## Pin ids that default to a module name
|
|
17
|
+
|
|
18
|
+
A reactor, reducer or declarative projection with no `:id` defaults it from its module name (reactors and reducers: the full module name; projections: the last segment). **Renaming or moving the module then creates a different artifact and silently resets its observation position.** Pin it:
|
|
19
|
+
|
|
20
|
+
```elixir
|
|
21
|
+
use Chronicle.Reactors.Reactor, id: "notification-reactor"
|
|
22
|
+
use Chronicle.Projections.Projection, id: "account-summary", model: MyApp.ReadModels.AccountSummary
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Discovery logs a warning once per module per BEAM node for a defaulted id. `warn_defaulted_ids: false` on `Chronicle.Client` suppresses it; prefer pinning. In-memory test clients skip it.
|
|
26
|
+
|
|
27
|
+
## Drift policy
|
|
28
|
+
|
|
29
|
+
Before registering, the client compares what it would register with the kernel. The `:drift_policy` client option:
|
|
30
|
+
|
|
31
|
+
| Value | Behavior |
|
|
32
|
+
| --- | --- |
|
|
33
|
+
| `:warn` (default) | Log findings and register anyway |
|
|
34
|
+
| `:fail` | Log findings and refuse to register on a `:warning`-severity finding |
|
|
35
|
+
| `:ignore` | Skip the check and its kernel calls |
|
|
36
|
+
|
|
37
|
+
Findings: `:schema_changed_without_generation` (an event type generation's coarse shape changed: added, removed or retyped field), `:observer_missing_from_code` (always `:info`, never blocks: other clients may own the observer), `:observer_event_types_changed` (a reactor's handled types differ; `:warning`). An indeterminate check (kernel unreachable, exception, 5 s deadline) continues under `:warn` and stops under `:fail`. Read the current findings with `Chronicle.Client.identity_drift()`. The kernel rejects a schema change without a bumped generation itself, regardless of policy.
|
|
38
|
+
|
|
39
|
+
## Event type generations
|
|
40
|
+
|
|
41
|
+
A module for an **older** generation uses `generation_for:` instead of its own `:id`, so the ids cannot drift apart:
|
|
42
|
+
|
|
43
|
+
```elixir
|
|
44
|
+
defmodule MyApp.Events.CustomerRegisteredV1 do
|
|
45
|
+
use Chronicle.Events.EventType, generation_for: MyApp.Events.CustomerRegistered, generation: 1
|
|
46
|
+
defstruct customer_id: "", owner_name: ""
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`generation_for:` requires an explicit `generation:` and cannot be combined with `:id`. Bridge the generations with `Chronicle.Events.Migration` (exactly one migrator per pair).
|
|
51
|
+
|
|
52
|
+
Mistakes such as two modules for one id and generation, two migrators for a pair, or `generation_for:` naming a module that is not an event type are logged and the client continues. Set `strict_validation: true` on `Chronicle.Client` to raise instead; `Chronicle.Events.EventTypes.validate/2` returns `:ok | {:error, exception}` and `validate!/2` raises.
|
|
53
|
+
|
|
54
|
+
## Pinned generation delivery
|
|
55
|
+
|
|
56
|
+
By default (`:compatibility`) every event is upgraded to the generation of the module a reactor or reducer handles. For a handler written against an **older** generation, `use Chronicle.Reactors.Reactor, generation_delivery: :pinned` (or the same on a reducer) delivers the event as appended. `context.appended_generation` reports the appended generation (`0` means unknown). The client-wide default is `event_generation_delivery: :pinned`; an artifact's own option wins. A pinned observer that handles two generations of one event type raises `Chronicle.Observers.MultipleGenerationsOfEventTypeInObserver` on start.
|
|
@@ -63,4 +63,4 @@ Without an explicit `:concurrency_scope`, the client reads the tail narrowed by
|
|
|
63
63
|
|
|
64
64
|
The wire carries one scope per event source id. Guarded entries of that id must agree on dimensions and selected source type, stream type and stream id. Unguarded entries never suppress a later guarded entry. Incompatible derived guards fail before any tail read or append; use separate batches, or deliberately supply one explicit scope on an entry for that id.
|
|
65
65
|
|
|
66
|
-
Reactor and reducer contexts expose `:event_source` as the registered name, or `nil` for events appended without a definition.
|
|
66
|
+
Reactor and reducer contexts expose `:event_source` as the registered name, or `nil` for events appended without a definition. Only reactor contexts carry `:delivery_id`.
|
package/package/corpus/skills/cratis-chronicle-client-elixir/references/idioms-and-inspection.md
CHANGED
|
@@ -25,8 +25,8 @@ client-side. Observer types include `:reactor`, `:projection`, `:reducer`,
|
|
|
25
25
|
- **Discovery is compile-time reflection.** The `use` macros generate
|
|
26
26
|
`__chronicle_*__/1` functions via `__before_compile__`, and discovery is
|
|
27
27
|
`function_exported?/3` over the application's modules — not runtime scanning.
|
|
28
|
-
- **
|
|
29
|
-
|
|
28
|
+
- **Telemetry is built in.** The client emits OpenTelemetry spans and `:telemetry`
|
|
29
|
+
events; read [observability.md](observability.md). Logging is `Logger`.
|
|
30
30
|
- **No formatter export.** `.formatter.exs` declares no `locals_without_parens`
|
|
31
31
|
for `from`, `join`, `removed_with`, `from_every`, `pii`, `subject`, `unique`, or
|
|
32
32
|
`unique_event_type`, and there is no `import_deps: [:cratis_chronicle]` to
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<!-- Copyright (c) Cratis. All rights reserved. -->
|
|
2
|
+
<!-- Licensed under the MIT license. See LICENSE file in the project root for full license information. -->
|
|
3
|
+
|
|
4
|
+
# Observability: OpenTelemetry and `:telemetry`
|
|
5
|
+
|
|
6
|
+
Source: `Cratis/Chronicle.Elixir`: `Source/chronicle/lib/chronicle/diagnostics/tracing.ex`, `connections/trace_propagation_interceptor.ex`, and `Documentation/observability.md`.
|
|
7
|
+
|
|
8
|
+
Requires `cratis_chronicle` 3.13.0.
|
|
9
|
+
|
|
10
|
+
The client is always instrumented and depends only on `opentelemetry_api`. With no OpenTelemetry SDK started in the release, every span and propagation call is a no-op and nothing leaves the process; there is no Chronicle-specific enable switch. To export, add `opentelemetry` and `opentelemetry_exporter` to the application and configure them the standard way (`OTEL_*` variables or `config :opentelemetry`).
|
|
11
|
+
|
|
12
|
+
All spans use the instrumentation scope `Cratis.Chronicle.Client`, shared with the other Chronicle clients.
|
|
13
|
+
|
|
14
|
+
| Span | Kind | Where |
|
|
15
|
+
| --- | --- | --- |
|
|
16
|
+
| `cratis.chronicle.client.event_sequence.append` | Client | `append/3`, `append_with_result/3` |
|
|
17
|
+
| `cratis.chronicle.client.event_sequence.append_many` | Client | `append_many/3` variants, `append_many_for_event_sources/2`, unit-of-work commit |
|
|
18
|
+
| `cratis.chronicle.client.reactor.handle` | Consumer | a reactor delivery |
|
|
19
|
+
| `cratis.chronicle.client.reducer.reduce` | Consumer | a reducer delivery |
|
|
20
|
+
|
|
21
|
+
Attributes: `cratis.event_store.name`, `cratis.event_store.namespace`, `cratis.event_sequence.id`, `cratis.event_type.id` (append spans), `cratis.event.count` (batches), `cratis.observer.id`, `cratis.correlation_id`. **`cratis.event_source.id` is off by default** because an event source id can be sensitive; enable it with `config :cratis_chronicle, trace_event_source_id: true`.
|
|
22
|
+
|
|
23
|
+
A span is marked error when the operation returns `{:error, _}`, and a raised exception is recorded before re-raising. The status description is a fixed classification; messages, reasons and stacktraces are never put on a span because they can carry payloads.
|
|
24
|
+
|
|
25
|
+
## `:telemetry` events
|
|
26
|
+
|
|
27
|
+
For metrics built on `:telemetry`, the same operations emit spans of `:start`, `:stop` and `:exception` events:
|
|
28
|
+
|
|
29
|
+
- `[:cratis, :chronicle, :client, :append, ...]`
|
|
30
|
+
- `[:cratis, :chronicle, :client, :reactor, :handle, ...]`
|
|
31
|
+
- `[:cratis, :chronicle, :client, :reducer, :reduce, ...]`
|
|
32
|
+
|
|
33
|
+
The `:stop` metadata carries `error: true` when the operation returned `{:error, _}`. Observer lag is a separate event, `[:chronicle, :observer, :lag]`.
|
|
34
|
+
|
|
35
|
+
## Trace propagation
|
|
36
|
+
|
|
37
|
+
Every outbound gRPC call carries W3C `traceparent`/`tracestate` for the span active in the **calling process**, injected by `Chronicle.Connections.TracePropagationInterceptor` (always installed). A trace continues into the kernel only when the append or query runs inside the active span (for example inside `OpenTelemetry.Tracer.with_span/2`); a `Task` is a different process. The kernel does not store trace context on events, so an observer's span is not linked to the append that caused it. OpenTelemetry metrics instruments are not emitted, only `:telemetry` events.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<!-- Copyright (c) Cratis. All rights reserved. -->
|
|
2
|
+
<!-- Licensed under the MIT license. See LICENSE file in the project root for full license information. -->
|
|
3
|
+
|
|
4
|
+
# Behavior patterns
|
|
5
|
+
|
|
6
|
+
Source: `Cratis/Chronicle.Elixir`: `Source/chronicle/lib/chronicle/patterns.ex` and `Documentation/patterns.md`.
|
|
7
|
+
|
|
8
|
+
Requires `cratis_chronicle` 3.13.0.
|
|
9
|
+
|
|
10
|
+
`Chronicle.Patterns` queries the recurring behavior (combinations of facets such as `Day`, `TimeBucket`, `CommandType`, `AggregateType`) that Chronicle mined from an event store's history for a scope, for example one user. **An empty result is an answer, not a failure**: the scope has no established behavior for that context. Treat it as "do not claim to know".
|
|
11
|
+
|
|
12
|
+
```elixir
|
|
13
|
+
{:ok, patterns} = Chronicle.Patterns.get_patterns_at("user-42")
|
|
14
|
+
{:ok, patterns} = Chronicle.Patterns.get_patterns_at("user-42", moment, also_constraining: %{Chronicle.Patterns.FacetName.aggregate_type() => "Invoice"})
|
|
15
|
+
action = pattern.facets[Chronicle.Patterns.FacetName.command_type()]
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- Default to `get_patterns_at/3` ("what does this scope usually do now"). It derives `Day` and `TimeBucket` from the moment's **own offset**, not UTC, with the rule the kernel mined with; the moment defaults to now.
|
|
19
|
+
- Use `get_patterns/3` and `get_usual_actions/3` only for a hand-built facet map; `get_usual_actions/3` returns at most one answer per action.
|
|
20
|
+
- `get_patterns_for_scope/2` and `get_scopes/1` inspect what has been mined.
|
|
21
|
+
- `Chronicle.Patterns.time_bucket/1` exposes the kernel's part-of-day rule (`:early_morning`, `:morning`, `:midday`, `:afternoon`, `:evening`, `:night`). **Never write a copy**: a slot the mining never used looks exactly like "nothing established".
|
|
22
|
+
- Options on every call: `:client`, `:namespace`, `:minimum_confidence` (0.0 to 1.0) and `:max_results`. The all-patterns query is not exposed.
|
|
@@ -16,10 +16,18 @@ Source: `Cratis/Chronicle.Elixir`.
|
|
|
16
16
|
| Assuming the namespace default is `"default"` | It is `"Default"`; the moduledoc is wrong |
|
|
17
17
|
| `:ok = Chronicle.append(...)` in a server | A constraint violation returns `{:error, _}` and raises `MatchError` |
|
|
18
18
|
| Waiting for `:connected` | Observers attach at `:registered` |
|
|
19
|
-
| Reading `:correlation_id` from
|
|
19
|
+
| Reading `:correlation_id` or `:caused_by` from the context of an older client | It was a bare six-key map before the `EventContext` struct; see the upgrading reference |
|
|
20
20
|
| Two read models with the same final module segment | The default id is the last segment only |
|
|
21
21
|
| Appending from a `Task` and expecting the correlation id | Ambient context is per process |
|
|
22
22
|
| Treating quiet observers as "no events" | Keepalive eviction can silence observers while appends still succeed |
|
|
23
23
|
| Shipping the default TLS behavior | `skip_tls_validation` defaults to `true` |
|
|
24
24
|
| Forgetting `global_handlers:` or `:otp_app` discovery | Shared mappings are not merged into variants unless registered |
|
|
25
25
|
| A shared handler mapping a field one variant lacks | `GlobalHandlerPropertyNotOnVariant` at registration, not a silently skipped mapping |
|
|
26
|
+
| `DecisionReads.get/2` outside `Chronicle.decide/2` | Returns `{:error, :requires_unit_of_work}`; it guards nothing |
|
|
27
|
+
| Unset expected sequence number as "expect no event" | It disables the check; use `ConcurrencyScope.expecting_no_matching_event/1` |
|
|
28
|
+
| Passing no `client: client` in a `Chronicle.Testing.Case` spec | The call addresses the default client, not the test's |
|
|
29
|
+
| Trusting a spec that passed on the in-memory kernel | Queries, projections and `closes_stream` return `{:error, {:not_supported_in_memory, rpc}}`; use `Testing.Integration` |
|
|
30
|
+
| Renaming a reactor, reducer or projection module without a pinned `:id` | Its observation position silently resets |
|
|
31
|
+
| Capture event type ids with hyphens | `append/2,3` needs an uppercase-first, letters-digits-underscore id |
|
|
32
|
+
| Nested `children/3` ids reused across read model instances | Nested events can land on the wrong instance; use UUIDs |
|
|
33
|
+
| Reading a read model right after an append | `{:ok, nil}` means absent or not projected yet; use `Projections.wait_for/3` |
|
|
@@ -50,3 +50,31 @@ from_every(increment: [event_counts: {:event_context, :type}])
|
|
|
50
50
|
```
|
|
51
51
|
|
|
52
52
|
`decrement:` supports the corresponding dictionary operation. Use typed defaults for numeric and boolean fields on every model: schemas are generated from those defaults, not static Elixir types.
|
|
53
|
+
|
|
54
|
+
## Child collections
|
|
55
|
+
|
|
56
|
+
Requires `cratis_chronicle` 3.13.0 for the one-line form, `removed_with_join/2`, `clear_with/2` and the compile-time checks below; `children/2,3` itself requires 3.11.0.
|
|
57
|
+
|
|
58
|
+
`children/2,3` declares a list on the parent that grows, changes and shrinks with events. It works in `use Chronicle.ReadModels.ReadModel` and `use Chronicle.Projections.Projection`; declare the list field's element type with `types: [items: {:list, MyApp.ReadModels.BasketItem}]`.
|
|
59
|
+
|
|
60
|
+
```elixir
|
|
61
|
+
children :items, identified_by: :item_id do
|
|
62
|
+
from MyApp.Events.ItemAdded,
|
|
63
|
+
key: :item_id,
|
|
64
|
+
parent_key: :event_source_id,
|
|
65
|
+
set: [item_id: :item_id, name: :name]
|
|
66
|
+
|
|
67
|
+
removed_with MyApp.Events.ItemRemoved, key: :item_id, parent_key: :event_source_id
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
A collection that only grows from one event needs no block: `children :entries, from: MyApp.Events.EntryAdded, identified_by: :entry_id, key: :entry_id, set: [entry_id: :entry_id, name: :name]`.
|
|
72
|
+
|
|
73
|
+
- Inside the block `from/2`, `join/2`, `removed_with/2`, `removed_with_join/2`, `clear_with/2`, `from_every/1` and a nested `children/3` take the options they take on the read model. `key` selects the child an event applies to; `parent_key` selects its parent.
|
|
74
|
+
- A `join` in a children block defaults `:on` to `identified_by`; an explicit disagreeing `:on` compiles with a warning. A root `join/2` still requires `:on`.
|
|
75
|
+
- `removed_with_join event, key: :archived_entry_id` removes a child when an event from a **different** event source matches it by property instead of the collection key.
|
|
76
|
+
- `clear_with event, [:field]` (or `clear: [:field]` in a mapping) sets a scalar back to no value. The field must default to `nil`; a field defaulting to `""` is not nullable to the kernel, so clearing writes the type default.
|
|
77
|
+
- **Give nested child collections globally unique identifiers (UUIDs).** In a nested `children/3`, `parent_key` names a child of the collection one level up, and the kernel finds it across every instance of the read model; two instances that both hold `"e-1"` can receive each other's nested events.
|
|
78
|
+
- Compile-time errors cover a `children` without `:identified_by`, a root `join/2` without `:on`, one target property assigned twice by one event, and a `from`/`join` target or `children` name that is not a struct field.
|
|
79
|
+
|
|
80
|
+
A read model document is limited to 4 MiB; give a child collection that can grow without bound its own read model.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<!-- Copyright (c) Cratis. All rights reserved. -->
|
|
2
|
+
<!-- Licensed under the MIT license. See LICENSE file in the project root for full license information. -->
|
|
3
|
+
|
|
4
|
+
# Querying, observing and previewing read models
|
|
5
|
+
|
|
6
|
+
Source: `Cratis/Chronicle.Elixir`: `Source/chronicle/lib/chronicle/read_models.ex`, `read_models/watchers.ex`, `read_models/reactor.ex`, `projections.ex`, and `Documentation/read-models.md`, `projections.md`.
|
|
7
|
+
|
|
8
|
+
Requires `cratis_chronicle` 3.13.0 for everything below except `watch/2`.
|
|
9
|
+
|
|
10
|
+
A read model is eventually consistent: `Chronicle.read_model/3` returns `{:ok, nil}` both for an absent key and for one not projected yet. To read your own write, append with `append_and_wait_for_completion/3` or call `Chronicle.Projections.wait_for/3`.
|
|
11
|
+
|
|
12
|
+
## Wait for a projection
|
|
13
|
+
|
|
14
|
+
```elixir
|
|
15
|
+
{:ok, %{sequence_number: sequence}} = Chronicle.EventSequences.EventLog.append_with_result(id, event)
|
|
16
|
+
:ok = Chronicle.Projections.wait_for(MyApp.ReadModels.Account, sequence)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`wait_for/3` polls until the projection has handled that sequence number or returns `{:error, :timeout}`.
|
|
20
|
+
|
|
21
|
+
## Filter and sort
|
|
22
|
+
|
|
23
|
+
`Chronicle.ReadModels.query/2` accepts `:filter` (all conditions must match; a value means equality, or `{:gt, v}`, `{:gte, v}`, `{:lt, v}`, `{:lte, v}`, `{:ne, v}`, `{:in, [v]}`, `{:contains, v}`) and `:sort` (`{:amount, :desc}`). An unknown field returns `{:error, {:unknown_field, field}}`.
|
|
24
|
+
|
|
25
|
+
**The client applies the filter** by paging through the whole read model, because the kernel contract cannot filter. A read model with more than `:max_scan` instances (default `10_000`) returns `{:error, {:scan_limit_exceeded, max_scan}}` rather than a partial answer. For large data, give the question its own projection.
|
|
26
|
+
|
|
27
|
+
## Live changes: pick by what you need
|
|
28
|
+
|
|
29
|
+
| Need | Function | Messages the process receives |
|
|
30
|
+
| --- | --- | --- |
|
|
31
|
+
| Changes to a read model, one stream per caller | `watch/2` / `unwatch/1` | `{:chronicle_read_model_changed, module, %Changeset{}}`, `{:chronicle_read_model_watch_error, module, reason}` (the watch has ended; call again) |
|
|
32
|
+
| Same, shared across callers (default) | `subscribe/2` / `unsubscribe/1` | the two above plus `{:chronicle_read_model_watch_ended, module, reason}` and `{:chronicle_read_model_watch_resumed, module}` |
|
|
33
|
+
| The current page of instances, re-sent on change | `observe/2` / `unobserve/1`, or `observe_stream/2` as an Elixir `Stream` of `%QueryResult{}` | `{:chronicle_read_model_instances, module, %{instances: _}}`, `{:chronicle_read_model_observe_error, module, reason}` (observation ended) |
|
|
34
|
+
|
|
35
|
+
`subscribe/2` shares one kernel stream per read model, client, namespace and event sequence and reconnects with backoff (500 ms doubling to 30 s). **Changes made while the stream was down are not replayed**; query again after `:chronicle_read_model_watch_resumed`. `observe/2` accepts `:page_size`, `:filter` and `:sort`; with a filter, `total_count` counts matches within the delivered page only.
|
|
36
|
+
|
|
37
|
+
A `use Chronicle.ReadModels.Reactor, model: M` module reacts to read model changes through optional `on_added/2`, `on_modified/2` and `on_removed/2`, and is registered with the client's `:read_model_reactors` option. It subscribes through `subscribe/2` and re-subscribes after a reconnect.
|
|
38
|
+
|
|
39
|
+
## Ad-hoc queries and previews
|
|
40
|
+
|
|
41
|
+
`Chronicle.Projections.query/2` sends a Projection Declaration Language string to the kernel and returns the projected entries without registering a projection or defining a module; entries are plain maps unless `:read_model` is given. Bad syntax returns `{:error, {:invalid_declaration, [%{message:, line:, column:}]}}`. `Chronicle.Projections.preview/2` runs an **already registered** projection's declaration over the stored event sequence; previewing over events you supply is not possible.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<!-- Copyright (c) Cratis. All rights reserved. -->
|
|
2
|
+
<!-- Licensed under the MIT license. See LICENSE file in the project root for full license information. -->
|
|
3
|
+
|
|
4
|
+
# Testing against an in-memory kernel
|
|
5
|
+
|
|
6
|
+
Source: `Cratis/Chronicle.Elixir`: `Source/chronicle/lib/chronicle/testing.ex`, `testing/case.ex`, `testing/assertions.ex`, `testing/in_memory_kernel.ex`, `testing/integration.ex`, `testing/projections.ex`, and `Documentation/testing.md`.
|
|
7
|
+
|
|
8
|
+
Requires `cratis_chronicle` 3.13.0. Earlier clients ship no `Chronicle.Testing` modules.
|
|
9
|
+
|
|
10
|
+
## Default: `Chronicle.Testing.Case`
|
|
11
|
+
|
|
12
|
+
Use the case template for ExUnit specs. It starts a real `Chronicle.Client` against `Chronicle.Testing.InMemoryKernel`, puts the client name in the test context as `:chronicle`, and stops it on exit. Every test gets a unique event store, so it is safe under `async: true`.
|
|
13
|
+
|
|
14
|
+
```elixir
|
|
15
|
+
# Copyright (c) Cratis. All rights reserved.
|
|
16
|
+
# Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
17
|
+
|
|
18
|
+
defmodule MyApp.AccountTest do
|
|
19
|
+
use Chronicle.Testing.Case,
|
|
20
|
+
event_types: [MyApp.Events.AccountOpened],
|
|
21
|
+
reducers: [MyApp.Reducers.AccountReducer]
|
|
22
|
+
|
|
23
|
+
import Chronicle.Testing.Assertions
|
|
24
|
+
|
|
25
|
+
test "opening an account appends AccountOpened", %{chronicle: client} do
|
|
26
|
+
:ok =
|
|
27
|
+
Chronicle.EventSequences.EventLog.append(
|
|
28
|
+
"account-1",
|
|
29
|
+
%MyApp.Events.AccountOpened{owner_name: "Alice"},
|
|
30
|
+
client: client
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
assert_appended(client, MyApp.Events.AccountOpened, event_source_id: "account-1")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Pass `client: client` on every call.** Without it the call addresses the default `Chronicle.Client` name, not the test's client.
|
|
39
|
+
|
|
40
|
+
`start_supervised_client/1` (outside ExUnit templates; pair with `stop_supervised_client/1`) accepts `:event_types`, `:event_sources`, `:constraints` (standalone constraint modules), `:reactors`, `:reducers`, `:projections`, `:name`, `:event_store` and `:namespace`.
|
|
41
|
+
|
|
42
|
+
## Waiting and reading
|
|
43
|
+
|
|
44
|
+
An append returns once the event is committed; reactors and reducers run afterward in `Chronicle.Testing.Dispatcher`, synchronously after each committed append. Call `Chronicle.Testing.wait_for_observers(client)` before asserting on their effects; it is a sleep-free barrier that returns `{:error, failures}` when a reactor or reducer raised.
|
|
45
|
+
|
|
46
|
+
- `Chronicle.Testing.read_model(client, ReducerModule, key)` reads the state a **reducer** built, keyed by event source id.
|
|
47
|
+
- `Chronicle.Testing.appended_events(client, event_source_id)` returns decoded `Chronicle.Events.AppendedEvent` structs.
|
|
48
|
+
|
|
49
|
+
## Assertions
|
|
50
|
+
|
|
51
|
+
`assert_appended/3` and `refute_appended/3` take a matcher: an exact event struct (`==`), an event type module, or a one-arity predicate; options include `:event_source_id` and `:count`. `assert_constraint_violation(result, constraint_name \\ nil)` and `assert_concurrency_violation(result)` match the `{:error, {:constraint_violations, _}}` and `{:error, {:concurrency_violations, _}}` append results.
|
|
52
|
+
|
|
53
|
+
## What the in-memory kernel does not model
|
|
54
|
+
|
|
55
|
+
Appends, reads, the tail lookups, concurrency scopes (including `expecting_no_matching_event/1`), and `unique/2` and `unique_event_type/1` constraints are modeled. **Everything else returns `{:error, {:not_supported_in_memory, rpc}}`**: `closes_stream` constraints, `ReadModels` queries and `Projections` queries. A spec that passes against it proves client logic, not kernel behavior.
|
|
56
|
+
|
|
57
|
+
`Chronicle.Testing.Projections.apply_event(ReadModel, instance_or_nil, event)` folds one event through the supported subset of a read model's declarations (`from/2` with `:set`, `:add`, `:subtract`, `:count`, `:increment`, `:decrement`, `:clear`, AutoMap, and `removed_with/2`). `join/2`, `children/2`, `from_every/1` and `removed_with_join/2` raise `Chronicle.Testing.UnsupportedProjectionOperation`; test those against a real kernel.
|
|
58
|
+
|
|
59
|
+
## Real kernel: `Chronicle.Testing.Integration`
|
|
60
|
+
|
|
61
|
+
Tag these specs `@moduletag :integration`. `Chronicle.Testing.Integration.setup_isolated_client(opts, client \\ Chronicle.Client)` starts a real client with a unique event store and namespace, reading `CHRONICLE_INTEGRATION_CONNECTION_STRING` unless `:connection_string` is given, and waits for registration. Use `append_with_result/3` then `Integration.wait_for_observers(result.sequence_number, client: client)`; do not sleep. The stores it creates are not deleted afterward because the kernel has no delete call.
|
|
62
|
+
|
|
63
|
+
`Chronicle` adopts `Chronicle.Behaviour`, so code that only needs to be observed calling the facade can use a Mox mock (`Mox.defmock(Mock, for: Chronicle.Behaviour)`); Mox is not a dependency of the client.
|
|
@@ -32,3 +32,21 @@ Rejected appends (including concurrency/constraint failures) return `{:error, re
|
|
|
32
32
|
Declared `unique(..., message: ...)` violation messages are preserved on single, batch and transactional appends. Retain the constraint name and diagnostics when handling violations rather than assuming every message came from the kernel.
|
|
33
33
|
|
|
34
34
|
Requires `cratis_chronicle` 3.10.0 and Chronicle kernel 19.30.0 for named event source definitions.
|
|
35
|
+
|
|
36
|
+
## Behavior changes to review when raising the requirement
|
|
37
|
+
|
|
38
|
+
Requires `cratis_chronicle` 3.13.0 for the behaviors below; each is a difference an older application can observe.
|
|
39
|
+
|
|
40
|
+
| Behavior | Review |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| Reactor and reducer `context` is a `Chronicle.Events.EventContext` struct | Pattern matches on a bare map keep working; `correlation_id`, `caused_by`, `causation`, `tags`, `subject`, `appended_generation` and `replaying?` are now present |
|
|
43
|
+
| `drift_policy` defaults to `:warn` | Start-up makes extra kernel calls and logs findings; set `:ignore` to skip them, `:fail` to block on a drift warning |
|
|
44
|
+
| Discovery warns once per module for a reactor, reducer or projection with no pinned `:id` | Pin ids before renaming or moving modules; see [discovery-and-generations.md](discovery-and-generations.md) |
|
|
45
|
+
| Appending a struct that is not an event type returns `{:error, {:unknown_event_type, module}}` | Handle it as an ordinary append error |
|
|
46
|
+
| Spans and `:telemetry` events are always emitted | No action without an SDK; see [observability.md](observability.md) |
|
|
47
|
+
| The client installs a Logger filter for Mint response crash reports | Keep your own production filter for the remaining crash shapes |
|
|
48
|
+
| `children/2,3` and projection mistakes are rejected at compile time | Fix declarations that assigned one target twice or named a missing field |
|
|
49
|
+
|
|
50
|
+
Requires `cratis_chronicle` 3.11.0 for the context struct; it was a bare map before.
|
|
51
|
+
|
|
52
|
+
Capabilities covered by these references: decision reads ([decision-reads.md](decision-reads.md)), `Chronicle.Testing` ([testing.md](testing.md)), captures ([captures.md](captures.md)), standalone constraints ([constraints.md](constraints.md)), `Chronicle.Patterns` ([patterns.md](patterns.md)) and read model subscriptions ([read-models.md](read-models.md)). A git tag is not a Hex release: confirm the version is on hex.pm before depending on it.
|
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
"fixtures": [],
|
|
5
5
|
"assertions": [
|
|
6
6
|
{ "kind": "skill-contains", "value": "`Cratis/Chronicle.Elixir`" },
|
|
7
|
-
{ "kind": "skill-contains", "value": "{:cratis_chronicle, \"~> 3.
|
|
7
|
+
{ "kind": "skill-contains", "value": "{:cratis_chronicle, \"~> 3.13\"}" },
|
|
8
8
|
{ "kind": "skill-contains", "value": "ConnectionString.development()" },
|
|
9
9
|
{ "kind": "skill-contains", "value": "`~> 1.18`" },
|
|
10
10
|
{ "kind": "skill-contains", "value": "`nil` registers as string" },
|
|
11
|
-
{ "kind": "skill-contains", "value": "**The
|
|
12
|
-
{ "kind": "skill-contains", "value": "`
|
|
11
|
+
{ "kind": "skill-contains", "value": "**The context is a `Chronicle.Events.EventContext` struct**" },
|
|
12
|
+
{ "kind": "skill-contains", "value": "`delivery_id` (stable across replay and failed-partition" },
|
|
13
13
|
{ "kind": "skill-contains", "value": "**Page 1 is the second page**" },
|
|
14
14
|
{ "kind": "skill-contains", "value": "`:retry_attempts` is accepted but ignored", "file": "references/connections.md" },
|
|
15
15
|
{ "kind": "skill-contains", "value": "Client certificates cannot be combined with `disableTls=true`", "file": "references/connections.md" },
|
|
@@ -18,6 +18,19 @@
|
|
|
18
18
|
{ "kind": "skill-contains", "value": "`Chronicle.Events.ConcurrencyScope.none()`", "file": "references/event-sources.md" },
|
|
19
19
|
{ "kind": "skill-contains", "value": "**no many-event variant**", "file": "references/append-api.md" },
|
|
20
20
|
{ "kind": "skill-contains", "value": "`subject/1` is not imported by `EventType`", "file": "references/compliance.md" },
|
|
21
|
-
{ "kind": "skill-contains", "value": "calendar year, not the ISO week-year", "file": "references/projections.md" }
|
|
21
|
+
{ "kind": "skill-contains", "value": "calendar year, not the ISO week-year", "file": "references/projections.md" },
|
|
22
|
+
{ "kind": "skill-contains", "value": "Outside one it returns `{:error, :requires_unit_of_work}`", "file": "references/decision-reads.md" },
|
|
23
|
+
{ "kind": "skill-contains", "value": "**Leaving the expected sequence number unset turns the check off.**", "file": "references/decision-reads.md" },
|
|
24
|
+
{ "kind": "skill-contains", "value": "**Everything else returns `{:error, {:not_supported_in_memory, rpc}}`**", "file": "references/testing.md" },
|
|
25
|
+
{ "kind": "skill-contains", "value": "**Pass `client: client` on every call.**", "file": "references/testing.md" },
|
|
26
|
+
{ "kind": "skill-contains", "value": "`name:` is **required** on a standalone declaration.", "file": "references/constraints.md" },
|
|
27
|
+
{ "kind": "skill-contains", "value": "**Renaming or moving the module then creates a different artifact", "file": "references/discovery-and-generations.md" },
|
|
28
|
+
{ "kind": "skill-contains", "value": "`mix chronicle.discover --otp-app my_app`", "file": "references/discovery-and-generations.md" },
|
|
29
|
+
{ "kind": "skill-contains", "value": "**An appended event type's id must start with an uppercase letter", "file": "references/captures.md" },
|
|
30
|
+
{ "kind": "skill-contains", "value": "**Give nested child collections globally unique identifiers (UUIDs).**", "file": "references/projections.md" },
|
|
31
|
+
{ "kind": "skill-contains", "value": "**The client applies the filter**", "file": "references/read-models.md" },
|
|
32
|
+
{ "kind": "skill-contains", "value": "**`cratis.event_source.id` is off by default**", "file": "references/observability.md" },
|
|
33
|
+
{ "kind": "skill-contains", "value": "**An empty result is an answer, not a failure**", "file": "references/patterns.md" },
|
|
34
|
+
{ "kind": "skill-contains", "value": "`{:error, {:unknown_event_type, module}}`", "file": "references/append-api.md" }
|
|
22
35
|
]
|
|
23
36
|
}
|