@cratis/pi 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -0
- package/package.json +24 -0
- package/skills/cratis-application-react-specifications/LICENSE +2 -0
- package/skills/cratis-application-react-specifications/SKILL.md +192 -0
- package/skills/cratis-application-slice-specifications/LICENSE +2 -0
- package/skills/cratis-application-slice-specifications/SKILL.md +222 -0
- package/skills/cratis-arc-authentication-authorization-and-identity/LICENSE +2 -0
- package/skills/cratis-arc-authentication-authorization-and-identity/SKILL.md +233 -0
- package/skills/cratis-arc-authentication-authorization-and-identity/references/authentication.md +140 -0
- package/skills/cratis-arc-authentication-authorization-and-identity/references/authorization.md +112 -0
- package/skills/cratis-arc-authentication-authorization-and-identity/references/frontend.md +102 -0
- package/skills/cratis-arc-authentication-authorization-and-identity/references/local-development.md +85 -0
- package/skills/cratis-arc-authentication-authorization-and-identity/references/tenancy.md +87 -0
- package/skills/cratis-arc-command/LICENSE +2 -0
- package/skills/cratis-arc-command/SKILL.md +286 -0
- package/skills/cratis-arc-command/references/command-result.md +111 -0
- package/skills/cratis-arc-command/references/handler-shapes.md +135 -0
- package/skills/cratis-arc-command/references/proxy-generation.md +87 -0
- package/skills/cratis-arc-command/references/read-model-injection.md +97 -0
- package/skills/cratis-arc-command-execution/LICENSE +2 -0
- package/skills/cratis-arc-command-execution/SKILL.md +148 -0
- package/skills/cratis-arc-command-validation/LICENSE +2 -0
- package/skills/cratis-arc-command-validation/SKILL.md +230 -0
- package/skills/cratis-arc-ef-core-migration/LICENSE +2 -0
- package/skills/cratis-arc-ef-core-migration/SKILL.md +231 -0
- package/skills/cratis-arc-observable-query-http/LICENSE +2 -0
- package/skills/cratis-arc-observable-query-http/SKILL.md +157 -0
- package/skills/cratis-arc-query-paging/LICENSE +2 -0
- package/skills/cratis-arc-query-paging/SKILL.md +173 -0
- package/skills/cratis-arc-react-page/LICENSE +2 -0
- package/skills/cratis-arc-react-page/SKILL.md +291 -0
- package/skills/cratis-arc-react-page/references/data-page.md +125 -0
- package/skills/cratis-arc-react-page/references/data-tables.md +136 -0
- package/skills/cratis-arc-react-page/references/dialogs.md +238 -0
- package/skills/cratis-arc-react-page/references/mvvm.md +150 -0
- package/skills/cratis-arc-react-page/references/queries-and-commands.md +200 -0
- package/skills/cratis-chronicle-cli-operations/LICENSE +2 -0
- package/skills/cratis-chronicle-cli-operations/SKILL.md +199 -0
- package/skills/cratis-chronicle-client-dotnet/LICENSE +2 -0
- package/skills/cratis-chronicle-client-dotnet/SKILL.md +433 -0
- package/skills/cratis-chronicle-client-elixir/LICENSE +2 -0
- package/skills/cratis-chronicle-client-elixir/SKILL.md +429 -0
- package/skills/cratis-chronicle-client-kotlin/LICENSE +2 -0
- package/skills/cratis-chronicle-client-kotlin/SKILL.md +498 -0
- package/skills/cratis-chronicle-client-typescript/LICENSE +2 -0
- package/skills/cratis-chronicle-client-typescript/SKILL.md +396 -0
- package/skills/cratis-chronicle-compliance/LICENSE +2 -0
- package/skills/cratis-chronicle-compliance/SKILL.md +246 -0
- package/skills/cratis-chronicle-event-constraints/LICENSE +2 -0
- package/skills/cratis-chronicle-event-constraints/SKILL.md +227 -0
- package/skills/cratis-chronicle-event-modeling/LICENSE +2 -0
- package/skills/cratis-chronicle-event-modeling/SKILL.md +157 -0
- package/skills/cratis-chronicle-event-specifications/LICENSE +2 -0
- package/skills/cratis-chronicle-event-specifications/SKILL.md +200 -0
- package/skills/cratis-chronicle-event-type-migration/LICENSE +2 -0
- package/skills/cratis-chronicle-event-type-migration/SKILL.md +235 -0
- package/skills/cratis-chronicle-mcp-inspection/LICENSE +2 -0
- package/skills/cratis-chronicle-mcp-inspection/SKILL.md +79 -0
- package/skills/cratis-chronicle-mcp-inspection/references/blocked-tools.md +12 -0
- package/skills/cratis-chronicle-mcp-inspection/references/observational-tools.md +8 -0
- package/skills/cratis-chronicle-multi-tenancy/LICENSE +2 -0
- package/skills/cratis-chronicle-multi-tenancy/SKILL.md +198 -0
- package/skills/cratis-chronicle-projection/LICENSE +2 -0
- package/skills/cratis-chronicle-projection/SKILL.md +162 -0
- package/skills/cratis-chronicle-projection/references/fluent-builder.md +126 -0
- package/skills/cratis-chronicle-projection/references/model-bound-attributes.md +118 -0
- package/skills/cratis-chronicle-reactor/LICENSE +2 -0
- package/skills/cratis-chronicle-reactor/SKILL.md +344 -0
- package/skills/cratis-chronicle-read-model/LICENSE +2 -0
- package/skills/cratis-chronicle-read-model/SKILL.md +193 -0
- package/skills/cratis-chronicle-read-model/references/queries.md +142 -0
- package/skills/cratis-chronicle-read-model-specifications/LICENSE +2 -0
- package/skills/cratis-chronicle-read-model-specifications/SKILL.md +176 -0
- package/skills/cratis-chronicle-reducer/LICENSE +2 -0
- package/skills/cratis-chronicle-reducer/SKILL.md +173 -0
- package/skills/cratis-chronicle-web-workbench/LICENSE +2 -0
- package/skills/cratis-chronicle-web-workbench/SKILL.md +177 -0
- package/skills/cratis-cli-terminal-workbench/LICENSE +2 -0
- package/skills/cratis-cli-terminal-workbench/SKILL.md +156 -0
- package/skills/cratis-code-review/LICENSE +2 -0
- package/skills/cratis-code-review/SKILL.md +177 -0
- package/skills/cratis-components-accessibility/LICENSE +2 -0
- package/skills/cratis-components-accessibility/SKILL.md +176 -0
- package/skills/cratis-components-schema-editor/LICENSE +2 -0
- package/skills/cratis-components-schema-editor/SKILL.md +238 -0
- package/skills/cratis-components-stepper-command-dialog/LICENSE +2 -0
- package/skills/cratis-components-stepper-command-dialog/SKILL.md +209 -0
- package/skills/cratis-components-styling/LICENSE +2 -0
- package/skills/cratis-components-styling/SKILL.md +221 -0
- package/skills/cratis-components-toolbar/LICENSE +2 -0
- package/skills/cratis-components-toolbar/SKILL.md +259 -0
- package/skills/cratis-documentation-writing/LICENSE +2 -0
- package/skills/cratis-documentation-writing/SKILL.md +121 -0
- package/skills/cratis-event-model-diagram/LICENSE +2 -0
- package/skills/cratis-event-model-diagram/SKILL.md +142 -0
- package/skills/cratis-fundamentals-concept/LICENSE +2 -0
- package/skills/cratis-fundamentals-concept/SKILL.md +198 -0
- package/skills/cratis-fundamentals-type-discovery/LICENSE +2 -0
- package/skills/cratis-fundamentals-type-discovery/SKILL.md +152 -0
- package/skills/cratis-governed-release-methodology/LICENSE +2 -0
- package/skills/cratis-governed-release-methodology/SKILL.md +297 -0
- package/skills/cratis-lens-browser-extension/LICENSE +2 -0
- package/skills/cratis-lens-browser-extension/SKILL.md +239 -0
- package/skills/cratis-performance-review/LICENSE +2 -0
- package/skills/cratis-performance-review/SKILL.md +139 -0
- package/skills/cratis-screenplay-model-authoring/LICENSE +2 -0
- package/skills/cratis-screenplay-model-authoring/SKILL.md +231 -0
- package/skills/cratis-security-review/LICENSE +2 -0
- package/skills/cratis-security-review/SKILL.md +141 -0
- package/skills/cratis-specification-by-example/LICENSE +2 -0
- package/skills/cratis-specification-by-example/SKILL.md +188 -0
- package/skills/cratis-specifications-csharp/LICENSE +2 -0
- package/skills/cratis-specifications-csharp/SKILL.md +230 -0
- package/skills/cratis-specifications-csharp/references/application-scenarios.md +191 -0
- package/skills/cratis-specifications-csharp/references/csharp-patterns.md +142 -0
- package/skills/cratis-specifications-csharp/references/integration-specs.md +126 -0
- package/skills/cratis-specifications-typescript/LICENSE +2 -0
- package/skills/cratis-specifications-typescript/SKILL.md +202 -0
- package/skills/cratis-specifications-typescript/references/typescript-patterns.md +168 -0
- package/skills/cratis-stage-rendering-and-sandbox/LICENSE +2 -0
- package/skills/cratis-stage-rendering-and-sandbox/SKILL.md +230 -0
- package/skills/cratis-studio-mcp-safety-guidance/LICENSE +2 -0
- package/skills/cratis-studio-mcp-safety-guidance/SKILL.md +69 -0
- package/skills/cratis-studio-mcp-safety-guidance/references/blocked-tools.md +12 -0
- package/skills/cratis-studio-mcp-safety-guidance/references/observational-tools.md +8 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
---
|
|
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.
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# The Chronicle client for Elixir
|
|
8
|
+
|
|
9
|
+
`cratis_chronicle` is a **standalone client SDK**. It is a library, not a
|
|
10
|
+
framework: it starts no supervision tree of its own, you add `Chronicle.Client`
|
|
11
|
+
to yours, and everything after that is ordinary OTP.
|
|
12
|
+
|
|
13
|
+
## Verified product sources
|
|
14
|
+
|
|
15
|
+
This skill is verified against `Cratis/Chronicle.Elixir` at tag **`v2.2.0`**,
|
|
16
|
+
which is the version actually published on Hex.
|
|
17
|
+
|
|
18
|
+
| Fact | Value | Source |
|
|
19
|
+
| --- | --- | --- |
|
|
20
|
+
| OTP app / Hex package | `:cratis_chronicle` | `Source/chronicle/mix.exs:14` |
|
|
21
|
+
| Published version | `2.2.0` | hex.pm |
|
|
22
|
+
| Elixir requirement | `~> 1.14` | `Source/chronicle/mix.exs:16` |
|
|
23
|
+
| Contracts dependency | `cratis_chronicle_contracts` resolved to `16.13.4` | `Source/chronicle/mix.lock` |
|
|
24
|
+
|
|
25
|
+
> **The `v2.3.0` git tag was never published to Hex.** `mix deps.get` cannot
|
|
26
|
+
> fetch it. Anything introduced there — notably the `Chronicle.Concept` macro,
|
|
27
|
+
> which does not exist at `v2.2.0` — must not be documented as available.
|
|
28
|
+
|
|
29
|
+
> **The repository's `VERSION` file says `0.0.5` and is a build-time
|
|
30
|
+
> placeholder** overwritten by the publish workflow. Never quote it.
|
|
31
|
+
|
|
32
|
+
> **Do not copy from the repository's root `README.md`.** At `v2.2.0` it uses
|
|
33
|
+
> module names that do not exist — `use Chronicle.EventType`,
|
|
34
|
+
> `use Chronicle.ReadModel`, `use Chronicle.Reactor`, `use Chronicle.Reducer`,
|
|
35
|
+
> `use Chronicle.Seeder` (`README.md:9-13`, `:49`, `:54`). The real modules are
|
|
36
|
+
> namespaced: `Chronicle.Events.EventType`, `Chronicle.ReadModels.ReadModel`,
|
|
37
|
+
> `Chronicle.Reactors.Reactor`, `Chronicle.Reducers.Reducer`,
|
|
38
|
+
> `Chronicle.Seeding.Seeder`. **Copy from `Documentation/client-snippets/`
|
|
39
|
+
> instead** — those are CI-validated and correct at every version.
|
|
40
|
+
|
|
41
|
+
> `Documentation/get-started.md:12` pins `{:cratis_chronicle, "~> 0.1"}`, which
|
|
42
|
+
> does not match the published `2.2.0`. Use a `2.x` requirement.
|
|
43
|
+
|
|
44
|
+
## Adding it
|
|
45
|
+
|
|
46
|
+
```elixir
|
|
47
|
+
# mix.exs
|
|
48
|
+
defp deps do
|
|
49
|
+
[
|
|
50
|
+
{:cratis_chronicle, "~> 2.2"}
|
|
51
|
+
]
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The library declares `extra_applications: [:logger, :inets, :ssl]` and **no
|
|
56
|
+
`mod:`** (`Source/chronicle/mix.exs`), so nothing starts on its own. `:inets` and
|
|
57
|
+
`:ssl` back the least-connections load balancer's HTTP probes.
|
|
58
|
+
|
|
59
|
+
## Starting the client
|
|
60
|
+
|
|
61
|
+
`Chronicle.Client` is a `Supervisor` (`lib/chronicle/client.ex:129`) with
|
|
62
|
+
`start_link(opts \\ [])` (`:139`). Put it in your application's tree — this is
|
|
63
|
+
the CI-validated snippet from
|
|
64
|
+
`Documentation/client-snippets/get-started/console/connect.md`:
|
|
65
|
+
|
|
66
|
+
```elixir
|
|
67
|
+
defmodule MyApp.Application do
|
|
68
|
+
use Application
|
|
69
|
+
|
|
70
|
+
@impl true
|
|
71
|
+
def start(_type, _args) do
|
|
72
|
+
children = [
|
|
73
|
+
{Chronicle.Client,
|
|
74
|
+
connection_string: "chronicle://localhost:35000",
|
|
75
|
+
event_store: "quickstart",
|
|
76
|
+
otp_app: :my_app}
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Options
|
|
85
|
+
|
|
86
|
+
All configuration is **child-spec keyword options**. There is no
|
|
87
|
+
`Application.get_env` anywhere in the library and no `config/` directory —
|
|
88
|
+
`config.exs` does nothing for this client. Read the options at
|
|
89
|
+
`lib/chronicle/client.ex`:
|
|
90
|
+
|
|
91
|
+
| Option | Default | Line |
|
|
92
|
+
| --- | --- | --- |
|
|
93
|
+
| `:name` | `Chronicle.Client` | `:140` |
|
|
94
|
+
| `:connection_string` | `ConnectionString.default/0` | — |
|
|
95
|
+
| `:event_store` | `"default"` | `:159` |
|
|
96
|
+
| `:namespace` | `"Default"` — capital D | `:160` |
|
|
97
|
+
| `:discover` | `true` | `:161` |
|
|
98
|
+
| `:otp_app` | none; falls back to scanning loaded modules | `:168` |
|
|
99
|
+
| `:event_types`, `:migrations`, `:reactors`, `:reducers`, `:read_models`, `:projections`, `:seeders`, `:webhooks`, `:event_store_subscriptions` | `[]`, merged with discovered | `:186-197` |
|
|
100
|
+
| `:skip_tls_validation`, `:load_balancer`, `:grpc_options`, `:retry_attempts`, `:reconnect_base_delay`, `:reconnect_max_delay` | forwarded to the connection | — |
|
|
101
|
+
|
|
102
|
+
> The moduledoc at `client.ex:81` says the namespace defaults to `"default"`.
|
|
103
|
+
> The code at `:160` uses `"Default"`. **Trust the code.**
|
|
104
|
+
|
|
105
|
+
**Set `:otp_app`.** With it, discovery is scoped to your application's modules;
|
|
106
|
+
without it the client falls back to scanning every loaded module.
|
|
107
|
+
|
|
108
|
+
Configuration is stashed in `:persistent_term` and read back with
|
|
109
|
+
`Chronicle.Client.config/1` (`client.ex:150`). Note it stores only a subset —
|
|
110
|
+
`read_models`, `projections`, and `migrations` are not retrievable at runtime.
|
|
111
|
+
|
|
112
|
+
### The supervision tree
|
|
113
|
+
|
|
114
|
+
`Supervisor.init(children, strategy: :rest_for_one)` — `client.ex:308`. The
|
|
115
|
+
strategy is deliberate: the children are ordered connection → session →
|
|
116
|
+
registration → observers, so restarting the connection restarts everything that
|
|
117
|
+
depends on it, and nothing is left holding a dead channel.
|
|
118
|
+
|
|
119
|
+
### Multiple clients
|
|
120
|
+
|
|
121
|
+
Pass `name:` to `Chronicle.Client` and `client:` to every API call. Every public
|
|
122
|
+
function takes a `:client` option.
|
|
123
|
+
|
|
124
|
+
## Connection strings
|
|
125
|
+
|
|
126
|
+
`Chronicle.Connections.ConnectionString` accepts `chronicle://` and
|
|
127
|
+
`chronicle+srv://` (`lib/chronicle/connections/connection_string.ex:8`,
|
|
128
|
+
validated at `:342`). Default port is `35_000` (`:83`).
|
|
129
|
+
|
|
130
|
+
| Function | Line |
|
|
131
|
+
| --- | --- |
|
|
132
|
+
| `default/0` — `chronicle://localhost:35000` | `:144` |
|
|
133
|
+
| `development/0` — adds the dev client credentials | `:154` |
|
|
134
|
+
| `parse/1` | `:186` |
|
|
135
|
+
|
|
136
|
+
Query parameters include `apiKey`, `disableTls`, `skipTlsValidation`,
|
|
137
|
+
`certificatePath`, `certificatePassword`, `authPort`, `loadBalancer`,
|
|
138
|
+
`srvNameServer`. A `chronicle+srv://` string accepts only one host and raises
|
|
139
|
+
otherwise (`:197-198`).
|
|
140
|
+
|
|
141
|
+
> **`skip_tls_validation` defaults to `true`** — the struct default at `:112` and
|
|
142
|
+
> the query parse at `:214`. TLS is on but the certificate chain is not checked,
|
|
143
|
+
> because a development kernel serves a self-signed certificate. **A production
|
|
144
|
+
> connection string must carry `?skipTlsValidation=false`.**
|
|
145
|
+
|
|
146
|
+
## Defining event types
|
|
147
|
+
|
|
148
|
+
```elixir
|
|
149
|
+
defmodule MyApp.Events.<EventName> do
|
|
150
|
+
@moduledoc "<What happened, in the past tense.>"
|
|
151
|
+
|
|
152
|
+
use Chronicle.Events.EventType, id: "<event-name>"
|
|
153
|
+
|
|
154
|
+
defstruct [:<field>, :<other_field>]
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`use Chronicle.Events.EventType` takes `:id` through `Keyword.fetch!`
|
|
159
|
+
(`lib/chronicle/events/event_type.ex:68`) — **the id is required**, unlike the
|
|
160
|
+
.NET, Kotlin, and TypeScript clients where it defaults to the type name. Give it
|
|
161
|
+
a stable, kebab-case string and never change it. `:generation` defaults to `1`
|
|
162
|
+
(`:69`).
|
|
163
|
+
|
|
164
|
+
The macro imports constraint macros into your module (`:79-86`):
|
|
165
|
+
`unique/1`, `unique/2`, `unique_event_type/0`, `unique_event_type/1`,
|
|
166
|
+
`remove_constraint/1`, plus `Chronicle.Compliance.pii/1,2` (`:88`). The
|
|
167
|
+
equivalent accumulating module attributes `@unique`, `@unique_event_type`,
|
|
168
|
+
`@remove_constraint` also work.
|
|
169
|
+
|
|
170
|
+
**Struct fields are sent as camelCase.** `encode_event/1` converts snake_case to
|
|
171
|
+
camelCase and `Jason.encode!`s the result
|
|
172
|
+
(`lib/chronicle/event_sequences/event_log.ex`).
|
|
173
|
+
|
|
174
|
+
> The moduledoc claims Chronicle generates a `Jason.Encoder` implementation
|
|
175
|
+
> automatically. There is no `defimpl Jason.Encoder` in the macro — encoding
|
|
176
|
+
> happens in `encode_event/1`. Do not rely on the claim.
|
|
177
|
+
|
|
178
|
+
Schema evolution is
|
|
179
|
+
`use Chronicle.Events.Migration, from: {Mod, generation: n}, to: {Mod, generation: n + 1}`
|
|
180
|
+
with `upcast/1` and `downcast/1`; the generations must be exactly one apart or it
|
|
181
|
+
raises at compile time.
|
|
182
|
+
|
|
183
|
+
## Appending
|
|
184
|
+
|
|
185
|
+
```elixir
|
|
186
|
+
:ok =
|
|
187
|
+
Chronicle.append(book_id, %MyApp.Events.<EventName>{
|
|
188
|
+
<field>: "<value>"
|
|
189
|
+
})
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`Chronicle.append/3` delegates to `Chronicle.EventSequences.EventLog`
|
|
193
|
+
(`lib/chronicle.ex:164-165`):
|
|
194
|
+
|
|
195
|
+
```elixir
|
|
196
|
+
@spec append(String.t(), struct(), keyword()) :: :ok | {:error, term()}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
**The event source id comes first, the event second.** The return is a bare
|
|
200
|
+
`:ok`, not a result struct — this client's `append/3` deliberately carries no
|
|
201
|
+
sequence number, and `append_and_wait_for_completion/3` exists for when you need
|
|
202
|
+
the outcome.
|
|
203
|
+
|
|
204
|
+
| Function | Arity | Line in `event_log.ex` |
|
|
205
|
+
| --- | --- | --- |
|
|
206
|
+
| `append/3` | `(event_source_id, event, opts)` | `:99` |
|
|
207
|
+
| `append_many/3` | `(event_source_id, events, opts)` | `:120` |
|
|
208
|
+
| `append_many_for_event_sources/2` | `(events, opts)` | `:155` |
|
|
209
|
+
| `append_and_wait_for_completion/3` | returns `{:ok, %{success: _, failed_partitions: _}}` | `:215` |
|
|
210
|
+
| `get_for_event_source/2` | | `:326` |
|
|
211
|
+
| `get_from_sequence_number/2` | | `:369` |
|
|
212
|
+
| `get_tail_sequence_number/2` | | `:408` |
|
|
213
|
+
|
|
214
|
+
Append options (`event_log.ex:79-94`): `:client`, `:namespace`,
|
|
215
|
+
`:event_sequence_id` (default `"event-log"`), `:event_source_type` (default
|
|
216
|
+
`"Default"`), `:event_stream_type` (default **`"All"`**), `:event_stream_id`
|
|
217
|
+
(default `"Default"`), `:tags`, `:subject`, `:correlation_id`, `:identity`,
|
|
218
|
+
`:causation`, `:concurrency_scope`, `:occurred`.
|
|
219
|
+
|
|
220
|
+
Errors are normalized to one of two shapes:
|
|
221
|
+
|
|
222
|
+
```elixir
|
|
223
|
+
{:error, {:constraint_violations, violations}}
|
|
224
|
+
{:error, {:append_errors, errors}}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
**Match on them.** A bare `:ok = Chronicle.append(...)` raises a `MatchError` on
|
|
228
|
+
a constraint violation, which is a fine choice in a script and the wrong one in a
|
|
229
|
+
GenServer.
|
|
230
|
+
|
|
231
|
+
A unit of work is a process: `Chronicle.begin_unit_of_work/1` starts an `Agent`,
|
|
232
|
+
and while one is current for the calling process `append`/`append_many` buffer
|
|
233
|
+
instead of sending, until `UnitOfWork.commit/1` or `rollback/1`.
|
|
234
|
+
|
|
235
|
+
## Observing
|
|
236
|
+
|
|
237
|
+
### Reactors
|
|
238
|
+
|
|
239
|
+
```elixir
|
|
240
|
+
defmodule MyApp.Reactors.<ReactorName> do
|
|
241
|
+
use Chronicle.Reactors.Reactor
|
|
242
|
+
|
|
243
|
+
alias MyApp.Events.<EventName>
|
|
244
|
+
|
|
245
|
+
@handles <EventName>
|
|
246
|
+
|
|
247
|
+
@impl true
|
|
248
|
+
def handle(%<EventName>{}, %{event_source_id: <id>}) do
|
|
249
|
+
# side effect here
|
|
250
|
+
:ok
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
That is the CI-validated snippet
|
|
256
|
+
(`Documentation/client-snippets/get-started/common/reactor.md`).
|
|
257
|
+
|
|
258
|
+
- **`@handles` is an accumulating module attribute** declaring the subscription;
|
|
259
|
+
dispatch is then ordinary Elixir pattern matching in `handle/2`. This is the
|
|
260
|
+
Elixir answer to the other clients' method-name or parameter-type conventions.
|
|
261
|
+
- The callback is
|
|
262
|
+
`handle(event :: struct(), context :: map()) :: :ok | {:error, term()} | {:ok, struct() | [struct()]}`.
|
|
263
|
+
- **Returning `{:ok, event_or_events}` appends those events as a side effect** —
|
|
264
|
+
to the same event source unless you return an `EventForEventSourceId`. A failed
|
|
265
|
+
side-effect append makes the whole `handle/2` result an error.
|
|
266
|
+
- The reactor id defaults to `to_string(__MODULE__)`, i.e. `"Elixir.My.Mod"`.
|
|
267
|
+
Pass `id:` for a stable, readable one.
|
|
268
|
+
- Optional replay callbacks: `on_replay_begin/0`, `on_replay_end/0`,
|
|
269
|
+
`on_partition_replay_begin/1`, `on_partition_replay_end/1`.
|
|
270
|
+
|
|
271
|
+
**The context map has exactly five keys** —
|
|
272
|
+
`%{event_source_id, sequence_number, occurred, event_store, namespace}`
|
|
273
|
+
(`lib/chronicle/reactors/handler.ex:393-397`). The reactor moduledoc also
|
|
274
|
+
mentions `:correlation_id`; **it is not in the map**.
|
|
275
|
+
|
|
276
|
+
### Read models and projections
|
|
277
|
+
|
|
278
|
+
```elixir
|
|
279
|
+
defmodule MyApp.ReadModels.<ReadModelName> do
|
|
280
|
+
use Chronicle.ReadModels.ReadModel
|
|
281
|
+
|
|
282
|
+
defstruct id: nil, <field>: nil, <flag>: false
|
|
283
|
+
|
|
284
|
+
from MyApp.Events.<EventName>,
|
|
285
|
+
set: [id: :event_source_id, <field>: :<event_field>]
|
|
286
|
+
|
|
287
|
+
from MyApp.Events.<OtherEvent>,
|
|
288
|
+
set: [<flag>: true]
|
|
289
|
+
end
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
That shape is the CI-validated snippet
|
|
293
|
+
(`Documentation/client-snippets/get-started/common/book-read-model.md`).
|
|
294
|
+
|
|
295
|
+
The DSL macros are `from/1`, `from/2`, `join/2`, `removed_with/2`, `from_every/1`.
|
|
296
|
+
`from/2` options: `:key` (defaults to `"$eventSourceId"`), `:parent_key`, `:set`,
|
|
297
|
+
`:add`, `:subtract`, `:count`.
|
|
298
|
+
|
|
299
|
+
> **The read model id sent to the kernel is the last module segment** —
|
|
300
|
+
> `Module.split() |> List.last()` at `lib/chronicle/read_models/read_model.ex:148`.
|
|
301
|
+
> `MyApp.ReadModels.Account` registers as `"Account"`, so two read models with the
|
|
302
|
+
> same final segment collide. Pass `id:` to disambiguate.
|
|
303
|
+
|
|
304
|
+
A standalone projection is `use Chronicle.Projections.Projection, model: Mod`
|
|
305
|
+
(`:model` is required); a reducer is
|
|
306
|
+
`use Chronicle.Reducers.Reducer, model: Mod` with
|
|
307
|
+
`reduce(event, model_or_nil, context) :: struct()`. **Reducers run in your
|
|
308
|
+
process**, so the reduction is Elixir code you own.
|
|
309
|
+
|
|
310
|
+
Querying (`lib/chronicle/read_models.ex`):
|
|
311
|
+
|
|
312
|
+
```elixir
|
|
313
|
+
{:ok, books} = Chronicle.all(MyApp.ReadModels.<ReadModelName>)
|
|
314
|
+
{:ok, book} = Chronicle.read_model(MyApp.ReadModels.<ReadModelName>, book_id)
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
`Chronicle.read_model/3` delegates to `ReadModels.get/3` (`lib/chronicle.ex:211`,
|
|
318
|
+
`read_models.ex:245-246`), returning `{:ok, struct() | nil} | {:error, term()}`.
|
|
319
|
+
`Chronicle.all/2` delegates to `ReadModels.get_instances/2`
|
|
320
|
+
(`lib/chronicle.ex:219`, `read_models.ex:314-315`).
|
|
321
|
+
|
|
322
|
+
**Live updates arrive as messages, not as a stream.** `ReadModels.watch/2`
|
|
323
|
+
(`read_models.ex:593`) sends
|
|
324
|
+
`{:chronicle_read_model_changed, module, %Changeset{}}` and
|
|
325
|
+
`{:chronicle_read_model_watch_error, module, reason}` to the calling process. This
|
|
326
|
+
is the Elixir analogue of the other clients' observable APIs, and it means the
|
|
327
|
+
receiving process must have a `handle_info` for both.
|
|
328
|
+
|
|
329
|
+
## Connecting is asynchronous — wait for the lifecycle
|
|
330
|
+
|
|
331
|
+
The client connects in the background. `Chronicle.Connections.Lifecycle`
|
|
332
|
+
broadcasts `{:chronicle_lifecycle, phase, connection_id}` with three phases
|
|
333
|
+
(`lib/chronicle/connections/lifecycle.ex:54`):
|
|
334
|
+
|
|
335
|
+
| Phase | Meaning |
|
|
336
|
+
| --- | --- |
|
|
337
|
+
| `:disconnected` | no live session with the kernel |
|
|
338
|
+
| `:connected` | the session handshake completed |
|
|
339
|
+
| `:registered` | the registration coordinator registered the base artifacts |
|
|
340
|
+
|
|
341
|
+
**Wait for `:registered`, never merely `:connected`** — the module's own docs say
|
|
342
|
+
so at `:27-29`, because reducers and reactors are not attached until registration
|
|
343
|
+
finishes.
|
|
344
|
+
|
|
345
|
+
```elixir
|
|
346
|
+
config = Chronicle.Client.config()
|
|
347
|
+
|
|
348
|
+
case Chronicle.Connections.Lifecycle.wait_until(config.lifecycle, :registered, 30_000) do
|
|
349
|
+
:ok -> :ok
|
|
350
|
+
{:error, :timeout} -> # decide what a not-yet-registered client means for you
|
|
351
|
+
end
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
`wait_until(lifecycle, target_phase, timeout \\ 30_000)` is at `:141`.
|
|
355
|
+
`subscribe/1` (`:90`) returns the current phase **and** sends it as a message,
|
|
356
|
+
which closes the subscribe-after-transition race; `phase/1` (`:98`) is the plain
|
|
357
|
+
read.
|
|
358
|
+
|
|
359
|
+
## Keepalive — the failure mode is silence
|
|
360
|
+
|
|
361
|
+
The contract is spelled out in `lib/chronicle/connections/keep_alive.ex:7-17`:
|
|
362
|
+
the kernel pushes a `ConnectionKeepAlive` down the `Connect` server stream once
|
|
363
|
+
per second, and for each one **the client must call back the separate unary
|
|
364
|
+
`ConnectionKeepAlive` RPC** (`answer/2` at `:56`). A client that only consumes the
|
|
365
|
+
stream is evicted once the kernel's `LastSeen` falls more than five seconds
|
|
366
|
+
behind, and the kernel then unsubscribes its observers.
|
|
367
|
+
|
|
368
|
+
The consequence is stated in that same comment and is worth carrying into any
|
|
369
|
+
diagnosis: **reactors and reducers go quiet while the `Connect` stream stays open
|
|
370
|
+
and every append keeps working.** Nothing raises. If observers stop firing but
|
|
371
|
+
appends succeed, look at the connection before you look at the observer.
|
|
372
|
+
|
|
373
|
+
Reconnect is exponential backoff — `:retry_attempts` 5, `:reconnect_base_delay`
|
|
374
|
+
1000 ms, `:reconnect_max_delay` 10000 ms — re-resolving addresses on every
|
|
375
|
+
attempt.
|
|
376
|
+
|
|
377
|
+
## Idioms worth knowing
|
|
378
|
+
|
|
379
|
+
- **Process-scoped context.** Correlation id, identity, and causation are held per
|
|
380
|
+
process: `Chronicle.current_correlation_id/0`, `set_correlation_id/1`,
|
|
381
|
+
`clear_correlation_id/0`; `current_identity/0`, `set_identity/1`,
|
|
382
|
+
`clear_identity/0`; `Chronicle.Auditing.CausationManager`. They are picked up
|
|
383
|
+
automatically on append. **A `Task` is a different process** and does not
|
|
384
|
+
inherit them.
|
|
385
|
+
- **Discovery is compile-time reflection.** The `use` macros generate
|
|
386
|
+
`__chronicle_*__/1` functions via `__before_compile__`, and discovery is
|
|
387
|
+
`function_exported?/3` over the application's modules — not runtime scanning.
|
|
388
|
+
- **No telemetry.** The client emits no `:telemetry` events; `telemetry` appears
|
|
389
|
+
only as a transitive dependency of `grpc`. Logging is plain `Logger`.
|
|
390
|
+
- **No formatter export.** `.formatter.exs` declares no `locals_without_parens`
|
|
391
|
+
for `from`, `join`, `removed_with`, `from_every`, `pii`, `subject`, `unique`, or
|
|
392
|
+
`unique_event_type`, and there is no `import_deps: [:cratis_chronicle]` to
|
|
393
|
+
inherit. `mix format` will add parentheses to the DSL.
|
|
394
|
+
- **`ReadModels.watch/2` is unsupervised.** It uses `Task.start/1` and `unwatch/1`
|
|
395
|
+
is a raw `Process.exit(pid, :shutdown)`. Supervise it yourself if it matters.
|
|
396
|
+
|
|
397
|
+
## Common pitfalls
|
|
398
|
+
|
|
399
|
+
| Pitfall | Why it bites |
|
|
400
|
+
| --- | --- |
|
|
401
|
+
| Copying the root `README.md` | Its module names do not exist at `v2.2.0` |
|
|
402
|
+
| Using `Chronicle.Concept` | It exists only in the unpublished `v2.3.0` tag |
|
|
403
|
+
| `{:cratis_chronicle, "~> 0.1"}` from the docs | Does not match the published `2.2.0` |
|
|
404
|
+
| Quoting the `VERSION` file | It is a `0.0.5` build-time placeholder |
|
|
405
|
+
| Putting configuration in `config.exs` | The client reads no application env; options are child-spec keywords |
|
|
406
|
+
| Omitting `:otp_app` | Discovery falls back to scanning every loaded module |
|
|
407
|
+
| Assuming the namespace default is `"default"` | It is `"Default"`; the moduledoc is wrong |
|
|
408
|
+
| `:ok = Chronicle.append(...)` in a server | A constraint violation returns `{:error, _}` and raises `MatchError` |
|
|
409
|
+
| Waiting for `:connected` | Observers attach at `:registered` |
|
|
410
|
+
| Reading `:correlation_id` from a reactor context | The map has five keys and that is not one |
|
|
411
|
+
| Two read models with the same final module segment | The id is the last segment only |
|
|
412
|
+
| Appending from a `Task` and expecting the correlation id | Ambient context is per process |
|
|
413
|
+
| Treating quiet observers as "no events" | Keepalive eviction silences observers while appends still succeed |
|
|
414
|
+
| Shipping the default TLS behavior | `skip_tls_validation` defaults to `true` |
|
|
415
|
+
|
|
416
|
+
## Verify
|
|
417
|
+
|
|
418
|
+
- `mix deps.get` resolves `cratis_chronicle` to the `2.x` version you intended.
|
|
419
|
+
- `Chronicle.Client` is in the supervision tree with an explicit `:otp_app`, and
|
|
420
|
+
the tree starts clean.
|
|
421
|
+
- The lifecycle reaches `:registered` before the application claims readiness.
|
|
422
|
+
- A production connection string sets `skipTlsValidation=false`.
|
|
423
|
+
- Every event type module passes an explicit, stable `id:`.
|
|
424
|
+
- Every `Chronicle.append/3` call site handles `{:error, _}` as well as `:ok`.
|
|
425
|
+
- Every reactor's `@handles` list matches the clauses of its `handle/2`.
|
|
426
|
+
- Read model module names have distinct final segments, or explicit ids.
|
|
427
|
+
- Every code example was copied from `Documentation/client-snippets/`, not from
|
|
428
|
+
the README.
|
|
429
|
+
- `mix compile --warnings-as-errors` and `mix test` are clean.
|