cratis-chronicle-client-elixir · git:20261014.3a23358 · 2026-10-14 · sha256 724d6422e6b5a1d4
cratis-chronicle-client-elixir git:20261014.3a23358A
Immutable. This exact content never changes and is served at /api/v1/blob/724d6422e6b5a1d4.
---
name: cratis-chronicle-client-elixir
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.
license: MIT
---
<!-- Copyright (c) Cratis. All rights reserved. -->
<!-- Licensed under the MIT license. See LICENSE file in the project root for full license information. -->
# The Chronicle client for Elixir
`cratis_chronicle` is a **standalone client SDK**. It is a library, not a
framework: it starts no supervision tree of its own, you add `Chronicle.Client`
to yours, and everything after that is ordinary OTP.
## Verified product sources
Source: `Cratis/Chronicle.Elixir` (Hex package `cratis_chronicle`). Source paths below are relative to `Source/chronicle/`.
Read [references/upgrading.md](references/upgrading.md) when moving an application from an older client; wire compatibility and corrected behavior both matter.
Requires Elixir `~> 1.18`. Read [references/product-sources.md](references/product-sources.md) for the package and contracts dependency facts.
> **A git tag is not a Hex release.** `mix deps.get` fetches only published versions. The `Chronicle.Concept` macro
> (`use Chronicle.Concept, type: :string`, a single-value concept that carries its
> `pii` classification once, mirroring `ConceptAs<T>`) is the preferred way to
> classify a personal value; a client without it annotates fields with `pii/1,2`.
Requires `cratis_chronicle` 3.0.0 for `Chronicle.Concept`.
> **The repository's `VERSION` file is a build-time placeholder** overwritten by the publish workflow. Never quote it.
> Use namespaced modules (`Chronicle.Events.EventType`, not `Chronicle.EventType`); older READMEs got this wrong. Maintained
> snippets live in `Documentation/client-snippets/`.
Select the published version from hex.pm rather than treating a guide's range as a pin.
## Adding it
```elixir
# mix.exs
defp deps do
[
{:cratis_chronicle, "~> 3.10"}
]
end
```
The library declares `extra_applications: [:logger, :inets, :ssl]` and **no
`mod:`** (`Source/chronicle/mix.exs`), so nothing starts on its own. `:inets` and
`:ssl` back the least-connections load balancer's HTTP probes.
## Starting the client
`Chronicle.Client` is a `Supervisor` (`lib/chronicle/client.ex`) with
`start_link(opts \\ [])`. Put it in your application's tree — this is
the CI-validated snippet from
`Documentation/client-snippets/get-started/console/connect.md`:
```elixir
defmodule MyApp.Application do
use Application
@impl true
def start(_type, _args) do
children = [
{Chronicle.Client,
connection_string: Chronicle.Connections.ConnectionString.development(),
event_store: "quickstart",
otp_app: :my_app}
]
Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end
end
```
### Options, supervision and multiple clients
Read [references/client-options.md](references/client-options.md) when setting `Chronicle.Client` options (set `:otp_app`), its supervision tree, or multiple clients.
## Connecting is asynchronous — wait for the lifecycle
The client connects in the background. `Chronicle.Connections.Lifecycle`
broadcasts `{:chronicle_lifecycle, phase, connection_id}` with three phases
(`lib/chronicle/connections/lifecycle.ex`):
| Phase | Meaning |
| --- | --- |
| `:disconnected` | no live session with the kernel |
| `:connected` | the kernel acknowledged the connection id with its first keepalive |
| `:registered` | the registration coordinator registered the base artifacts |
**Wait for `:registered`, never merely `:connected`** — the module's own docs say
so, because reducers and reactors are not attached until registration
finishes.
```elixir
alias Chronicle.Connections.Lifecycle
case Lifecycle.wait_until(Lifecycle.name_for(Chronicle.Client), :registered, 30_000) do
:ok -> :ready
{:error, :timeout} -> :not_ready
end
```
`wait_until(lifecycle, target_phase, timeout \\ 30_000)` is the blocking wait.
`subscribe/1` returns `{:ok, phase, connection_id}` **and** sends it as a message,
which closes the subscribe-after-transition race; `phase/1` is the plain
read.
Reconnect never gives up: exponential backoff starts at 1000 ms and caps at
10000 ms, re-resolving addresses each time. `:retry_attempts` is ignored.
Registration failures leave the client in `:connected`, log
`Chronicle registration failed`, and retry every five seconds. Appends before a
channel exists return `{:error, :not_connected}`; read model queries wait up to
30 seconds for `:registered`.
## Authentication and TLS
Use `ConnectionString.development()` locally: `default()` supplies **no
credentials**, so an authentication-required development kernel never lets it
reach `:registered`. Production uses URL userinfo `client-id:secret@host` or
`?apiKey=...` (the API key wins when both are given). Load real credentials from
the application's approved secret mechanism; never log the connection string.
**`skip_tls_validation` defaults to `true`.** Production must set
`skipTlsValidation=false`; a client certificate does not establish server trust.
Read [references/connections.md](references/connections.md) when configuring connection strings, mTLS, grpc/Mint options or credential-safe logging, or diagnosing keepalive and quiet observers.
## Defining event types
```elixir
# Copyright (c) Cratis. All rights reserved.
# Licensed under the MIT license. See LICENSE file in the project root for full license information.
defmodule MyApp.Events.FundsDeposited do
@moduledoc "Funds were deposited into an account."
use Chronicle.Events.EventType, id: "funds-deposited"
defstruct amount: 0, active: false, title: ""
end
```
`use Chronicle.Events.EventType` takes `:id` through `Keyword.fetch!`
(`lib/chronicle/events/event_type.ex`) — **the id is required**, unlike the
.NET, Kotlin, and TypeScript clients where it defaults to the type name. Give it
a stable, kebab-case string and never change it. `:generation` defaults to `1`.
**Use typed defaults on events and read models.** Schemas come from struct
defaults: `nil` registers as string, numbers as number, booleans as boolean;
list element schemas use the first default element. Nested structs and concepts
are descended into (`schemas/json_schema_generator.ex`).
The macro imports constraint macros into your module:
`unique/1`, `unique/2`, `unique_event_type/0`, `unique_event_type/1`,
`remove_constraint/1`, plus `Chronicle.Compliance.pii/1,2` and
`Chronicle.Confidentiality.encrypted/1,2,3`. The
equivalent accumulating module attributes `@unique`, `@unique_event_type`,
`@remove_constraint` also work.
**Only top-level event keys are camelCased.** `encode_event/1` transforms them
before `Jason.encode!` (`event_sequences/event_log.ex`); nested keys are
not transformed. Event structs get no `Jason.Encoder` implementation;
`Chronicle.Concept` does, encoding its wrapped value rather than a wrapper.
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.
Schema evolution is
`use Chronicle.Events.Migration, from: {Mod, generation: n}, to: {Mod, generation: n + 1}`
with `upcast/1` and `downcast/1`; the generations must be exactly one apart or it
raises at compile time.
## Appending
```elixir
case Chronicle.append("account-1", %MyApp.Events.FundsDeposited{amount: 50}) do
:ok -> :ok
{:error, {:constraint_violations, violations}} -> {:rejected, violations}
{:error, reason} -> {:error, reason}
end
```
`Chronicle.append/3` delegates to `Chronicle.EventSequences.EventLog`
(`lib/chronicle.ex`):
```elixir
@spec append(String.t(), struct(), keyword()) :: :ok | {:error, term()}
```
**The event source id comes first, the event second.** The return is a bare
`:ok`, not a result struct — this client's `append/3` deliberately carries no
sequence number, and `append_and_wait_for_completion/3` exists for when you need
the outcome.
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`.
Requires `cratis_chronicle` 3.2.0 for rejected appends to return `{:error, reason}`; an older client could report a rejection as `:ok`.
Failed appends return `{:error, reason}`. Reasons include `{:constraint_violations, violations}`,
`{:concurrency_violations, violations}`, `{:append_errors, errors}`,
`:not_connected`, and `{:incompatible_server, response}` from the first append's
compatibility preflight per channel. Pair the kernel and installed contracts;
do not blindly retry a timed-out write.
**Match on them.** A bare `:ok = Chronicle.append(...)` raises a `MatchError` on
a constraint violation, which is a fine choice in a script and the wrong one in a
GenServer.
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.
A unit of work is a process: `Chronicle.begin_unit_of_work/1` starts an `Agent`,
and while one is current for the calling process `append`/`append_many` buffer
instead of sending, until `UnitOfWork.commit/1` or `rollback/1`.
`commit/1` returns `:ok | {:error, reason}`. `Chronicle.with_unit_of_work(fun, opts)`
passes the unit to an arity-one function, commits on return, and raises on commit
failure. Source/stream options apply to buffered appends too.
## Observing
### Reactors
Read [references/reactor-example.md](references/reactor-example.md) when you need a worked reactor module.
This follows `Documentation/client-snippets/get-started/common/reactor.md`.
- **`@handles` is an accumulating module attribute** declaring the subscription;
dispatch is then ordinary Elixir pattern matching in `handle/2`. This is the
Elixir answer to the other clients' method-name or parameter-type conventions.
- The callback is
`handle(event :: struct(), context :: map()) :: :ok | {:error, term()} | {:ok, struct() | [struct()]}`.
- **Returning `{:ok, event_or_events}` appends those events as a side effect** —
to the same event source unless you return an `EventForEventSourceId`. A failed
side-effect append makes the whole `handle/2` result an error.
- The reactor id defaults to `to_string(__MODULE__)`, i.e. `"Elixir.My.Mod"`.
Pass `id:` for a stable, readable one.
- Optional replay callbacks: `on_replay_begin/0`, `on_replay_end/0`,
`on_partition_replay_begin/1`, `on_partition_replay_end/1`.
**The reactor context has six keys**: `event_source_id`, `sequence_number`,
`occurred`, `event_source`, `event_store`, `namespace`
(`lib/chronicle/reactors/handler.ex`). `event_source` is the registered
source name or `nil` for legacy appends. The moduledoc mentions `:correlation_id`,
but **it is not in the map**. Reducer context differs: `event_source_id`,
`sequence_number`, `occurred`, `event_source`, `observation_state`
(`reducers/handler.ex`).
### Read models and projections
Read [references/reactor-example.md](references/reactor-example.md#read-model-example) for a worked model-bound read model.
This is the model-bound shape; use typed defaults for its registered schema.
The DSL macros are `from/1`, `from/2`, `join/2`, `removed_with/2`, `from_every/1`.
`from/2` options: `:key` (defaults to `"$eventSourceId"`), `:parent_key`, `:set`,
`:add`, `:subtract`, `:count`. `join/2` requires `:on`.
Read [references/projections.md](references/projections.md) when choosing `passive`, `not_rewindable`, `event_sequence`, `no_auto_map`, composite/event-context keys or dictionary counters. Snake_case field atoms now map correctly; no camelCase-string workaround is needed.
> **The read model id sent to the kernel is the last module segment** —
> `Module.split() |> List.last()` at `lib/chronicle/read_models/read_model.ex`.
> `MyApp.ReadModels.Account` registers as `"Account"`, so two read models with the
> same final segment collide. Pass `id:` to disambiguate.
A standalone projection is `use Chronicle.Projections.Projection, model: Mod`
(`:model` is required); a reducer is
`use Chronicle.Reducers.Reducer, model: Mod` with
`reduce(event, model_or_nil, context) :: struct()`. **Reducers run in your
process**, so the reduction is Elixir code you own.
#### Variants — mutually exclusive read models for one entity's lifecycle
Requires `cratis_chronicle` `3.4.0` or later. `variant_of/2` requires `:key`; a variant with no `enters_on` raises `Chronicle.Projections.VariantMustDeclareEntersOnEvent` at registration.
Read [references/variants.md](references/variants.md) when an entity has mutually exclusive lifecycle shapes (for example a backlog entry that becomes a pull request) and you need variant read models or a `GlobalHandler`.
Querying (`lib/chronicle/read_models.ex`):
```elixir
{:ok, accounts} = Chronicle.all(MyApp.ReadModels.AccountBalance)
{:ok, account} = Chronicle.read_model(MyApp.ReadModels.AccountBalance, "account-1")
```
`Chronicle.read_model/3` delegates to `ReadModels.get/3` (`lib/chronicle.ex`,
`read_models.ex`), returning `{:ok, struct() | nil} | {:error, term()}`.
`Chronicle.all/2` delegates to `ReadModels.all/2`
(`lib/chronicle.ex`, `read_models.ex`).
Use `Chronicle.ReadModels.query(MyApp.ReadModels.AccountBalance, page: 0)` for paging:
`:page` is zero-based (default `0`), `:page_size` defaults to `50`, and
`:occurrence` selects a replay container. **Page 1 is the second page**. `ReadModels.snapshots/3`,
`occurrences/2`, and `definitions/1` expose history, replay containers, and
registered definitions.
**Live updates arrive as messages, not as a stream.** `ReadModels.watch/2`
(`read_models.ex`) sends
`{:chronicle_read_model_changed, module, %Changeset{}}` and
`{:chronicle_read_model_watch_error, module, reason}` to the calling process. The receiving process needs a `handle_info` for both.
## Idioms and operational inspection
Read [references/idioms-and-inspection.md](references/idioms-and-inspection.md) when carrying correlation or identity across a `Task`, formatting the DSL, supervising `ReadModels.watch/2`, or removing observers (obtain explicit authority first).
## Common pitfalls
Read [references/pitfalls.md](references/pitfalls.md) when troubleshooting discovery, defaults, process context, variant registration or older-version examples.
## Verify
- Elixir 1.18+ and `mix deps.get` resolve the intended published client; contracts
and kernel are compatible (event source definitions have their own client and kernel requirements; see the event sources reference).
- Local development uses `ConnectionString.development()` or explicit credentials.
- `Chronicle.Client` is in the supervision tree with an explicit `:otp_app`, and
the tree starts clean.
- The lifecycle reaches `:registered` before the application claims readiness.
- A production connection string sets `skipTlsValidation=false`.
- Every event type module passes an explicit, stable `id:`.
- Every `Chronicle.append/3` call site handles `{:error, _}` as well as `:ok`.
- Every reactor's `@handles` list matches the clauses of its `handle/2`.
- Read model module names have distinct final segments, or explicit ids.
- Every variant group has at least one `enters_on` per variant, and every
`GlobalHandler` member exists on every variant it targets.
- Production logging filters credential-bearing gRPC/Mint crash reports; see
the connections reference for limits.
- Examples match the APIs and schema defaults at the installed tag.
- `mix compile --warnings-as-errors` and `mix test` are clean.