Skip to content
Open
5 changes: 5 additions & 0 deletions .github/workflows/cleanup-pr-artifacts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ on:
pull_request:
types: [closed]

permissions:
actions: write
contents: read
pull-requests: write

jobs:
cleanup:
uses: Cratis/Workflows/.github/workflows/cleanup-pr-artifacts.yml@main
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/propagate-copilot-instructions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ on:
- ".github/hooks/**"
workflow_dispatch:

permissions:
contents: write
pull-requests: write

jobs:
propagate:
uses: Cratis/Workflows/.github/workflows/propagate-copilot-instructions.yml@main
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/sync-copilot-instructions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ on:
required: true
type: string

permissions:
contents: write
pull-requests: write

jobs:
sync:
uses: Cratis/Workflows/.github/workflows/sync-copilot-instructions.yml@main
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/update-packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ on:
- cron: '0 6 * * *'
workflow_dispatch:

permissions:
contents: write
pull-requests: write

jobs:
update:
uses: Cratis/Workflows/.github/workflows/update-packages.yml@main
Expand Down
73 changes: 73 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ Key features:
- **`use Chronicle.Reactor`** — react to events with side effects
- **`use Chronicle.Reducer`** — build read models by folding events into state
- **`use Chronicle.ReadModel`** — define read models with model-bound projections
- **Model-bound constraints** — declare unique and unique-event-type constraints on event types
- **Context-aware appends** — process-scoped identity, correlation, and causation metadata
- **Resilient connection** — automatic reconnection with exponential backoff
- **OTP-native** — fits naturally in your supervision tree

Expand Down Expand Up @@ -67,6 +69,29 @@ defmodule MyApp.ReadModels.Account do
end
```

### Constraints (model-bound)

Declare constraints directly on event types:

```elixir
defmodule MyApp.Events.UserRegistered do
use Chronicle.EventType, id: "user-registered-v1"
defstruct [:email, :tenant_id]

@unique [:email, :tenant_id]
unique_event_type()
end

defmodule MyApp.Events.UserDeleted do
use Chronicle.EventType, id: "user-deleted-v1"
defstruct [:email]

@remove_constraint "email"
end
```

Constraints declared this way are discovered and registered automatically during `Chronicle.Client` startup.

### 3. Define projection mappings (recommended)

Projection mappings are registered on Chronicle and executed server-side.
Expand Down Expand Up @@ -152,6 +177,54 @@ IO.inspect(account)
{:ok, accounts} = Chronicle.all(MyApp.ReadModels.Account)
```

You can also inspect event-store metadata and event-sequence state:

```elixir
{:ok, stores} = Chronicle.get_event_stores()
{:ok, namespaces} = Chronicle.get_namespaces()

{:ok, has_events?} = Chronicle.has_events_for?("account-42")
{:ok, tail_sequence_number} = Chronicle.get_tail_sequence_number("account-42")
```

### Correlation, identity, and causation

You can set process-scoped correlation, identity, and causation context.
`Chronicle.append/3` automatically includes this metadata on append requests.

```elixir
alias Chronicle.{CausationManager, CorrelationId, Identity}

Chronicle.set_correlation_id(CorrelationId.create())
Chronicle.set_identity(Identity.new("user-42", "Alice", "alice"))

CausationManager.define_root(%{application: "banking-api"})
CausationManager.add("Banking.Commands.OpenAccount", %{account_id: "account-42"})

:ok = Chronicle.append("account-42", %MyApp.Events.AccountOpened{...})

Chronicle.clear_identity()
Chronicle.clear_correlation_id()
CausationManager.clear()
```

For one-off overrides, pass explicit metadata as options:

```elixir
:ok =
Chronicle.append("account-42", event,
correlation_id: "92a130f7-16e2-44f7-a8e3-79e76f5df3e1",
identity: Chronicle.Identity.new("service-1", "Billing Service", "billing")
)
```

To append/query a non-default event sequence, pass `:event_sequence_id`:

```elixir
:ok = Chronicle.append("account-42", event, event_sequence_id: "audit-sequence")
{:ok, events} = Chronicle.EventLog.get_for_event_source("account-42", event_sequence_id: "audit-sequence")
```

## Quick Start (Reducer Alternative)

Use reducers when you want the read model folding logic in Elixir code in your app process.
Expand Down
12 changes: 11 additions & 1 deletion Samples/console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ A runnable example demonstrating the Chronicle Elixir client.
2. Reacts to those events via a `NotificationReactor` (prints log messages)
3. Projects them into an `Account` read model using model-bound `Chronicle.ReadModel` mappings
4. Also runs a reducer (`AccountReducer`) into `AccountSummary` as an alternative approach
5. Reads back the projection-backed `Account` model and prints its state
5. Registers a model-bound `unique_event_type` constraint on `AccountOpened`
6. Demonstrates process-scoped identity, correlation id, and causation chain for appends
7. Reads back the projection-backed `Account` model and prints its state
8. Queries event-sequence state (`has_events_for?`, `get_tail_sequence_number`)
9. Lists available event stores and namespaces from the kernel

## Prerequisites

Expand Down Expand Up @@ -40,6 +44,10 @@ You should see output similar to:
[info] [Reactor] Funds deposited: 500 to account-384291
[info] [Reactor] Funds withdrawn: 200 from account-384291
[info] Reading Account read model...
[info] Event sequence has events for account-384291
[info] Tail sequence number for account-384291: 3
[info] Event stores: ["default"]
[info] Namespaces: ["Default"]
[info] === Account State ===
[info] ID: account-384291
[info] Owner: Alice
Expand All @@ -65,6 +73,7 @@ lib/
application.ex # OTP Application (starts Chronicle.Client with auto-discovery)
events/
account_opened.ex # use Chronicle.EventType
# includes model-bound unique_event_type constraint
funds_deposited.ex
funds_withdrawn.ex
read_models/
Expand All @@ -74,4 +83,5 @@ lib/
notification_reactor.ex # use Chronicle.Reactor
reducers/
account_reducer.ex # use Chronicle.Reducer
# and sequence/store discovery calls in demo flow
```
37 changes: 37 additions & 0 deletions Samples/console/lib/console_sample.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ defmodule ConsoleSample do

alias ConsoleSample.Events.{AccountOpened, FundsDeposited, FundsWithdrawn}
alias ConsoleSample.ReadModels.Account
alias Chronicle.{CausationManager, CorrelationIdManager, Identity}

@doc """
Runs the demo scenario:
Expand All @@ -25,9 +26,20 @@ defmodule ConsoleSample do
Process.sleep(8_000)

account_id = "account-#{:rand.uniform(1_000_000)}"
correlation_id = Chronicle.CorrelationId.create()

Chronicle.set_correlation_id(correlation_id)

Chronicle.set_identity(
Identity.new("console-sample-user", "Console Sample", "console-sample")
)

CausationManager.define_root(%{application: "console-sample"})
CausationManager.add("ConsoleSample.RunDemo", %{account_id: account_id})

Logger.info("=== Chronicle Elixir Console Sample ===")
Logger.info("Using account ID: #{account_id}")
Logger.info("Correlation ID: #{correlation_id.value}")

Logger.info("Appending AccountOpened event...")

Expand Down Expand Up @@ -83,6 +95,31 @@ defmodule ConsoleSample do
Logger.error("Failed to read projection model: #{inspect(reason)}")
end

case Chronicle.has_events_for?(account_id) do
{:ok, true} -> Logger.info("Event sequence has events for #{account_id}")
{:ok, false} -> Logger.warning("No events found for #{account_id}")
{:error, reason} -> Logger.error("Failed checking sequence state: #{inspect(reason)}")
end

case Chronicle.get_tail_sequence_number(account_id) do
{:ok, sequence_number} -> Logger.info("Tail sequence number for #{account_id}: #{sequence_number}")
{:error, reason} -> Logger.error("Failed getting tail sequence number: #{inspect(reason)}")
end

case Chronicle.get_event_stores() do
{:ok, stores} -> Logger.info("Event stores: #{inspect(stores)}")
{:error, reason} -> Logger.error("Failed getting event stores: #{inspect(reason)}")
end

case Chronicle.get_namespaces() do
{:ok, namespaces} -> Logger.info("Namespaces: #{inspect(namespaces)}")
{:error, reason} -> Logger.error("Failed getting namespaces: #{inspect(reason)}")
end

Logger.info("=== Demo complete ===")

Chronicle.clear_identity()
CorrelationIdManager.clear()
CausationManager.clear()
end
end
2 changes: 2 additions & 0 deletions Samples/console/lib/console_sample/events/account_opened.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ defmodule ConsoleSample.Events.AccountOpened do

use Chronicle.EventType, id: "account-opened"

unique_event_type()

defstruct account_id: nil, owner_name: nil, initial_balance: 0

@type t :: %__MODULE__{
Expand Down
71 changes: 71 additions & 0 deletions Source/chronicle/lib/chronicle.ex
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,15 @@ defmodule Chronicle do
## Modules

* `Chronicle.Client` — the main supervisor; start it in your supervision tree
* `Chronicle.CorrelationId` / `Chronicle.CorrelationIdManager` — correlate operations
* `Chronicle.Identity` / `Chronicle.IdentityProvider` — track who caused state changes
* `Chronicle.CausationType`, `Chronicle.CausationEntry`, `Chronicle.CausationManager` — audit causation chains
* `Chronicle.EventType` — macro for defining event types
* `Chronicle.Reactor` — behaviour for event reactors
* `Chronicle.Reducer` — behaviour for read model reducers
* `Chronicle.ReadModel` — macro for read model structs with embedded projection DSL
* `Chronicle.EventLog` — append and query events
* `Chronicle.EventStores` — list event stores and namespaces
* `Chronicle.ReadModels` — query read model instances
* `Chronicle.Connections.ConnectionString` — parse and format connection strings
* `Chronicle.Connections.Connection` — resilient gRPC channel management
Expand All @@ -113,8 +117,12 @@ defmodule Chronicle do

* `:client` — the client name (default: `Chronicle.Client`)
* `:namespace` — overrides the client's default namespace
* `:event_sequence_id` — event sequence id (default: `"event-log"`)
* `:tags` — list of tag strings
* `:subject` — the identity subject string
* `:correlation_id` — `Chronicle.CorrelationId` (or id string) override
* `:identity` — `Chronicle.Identity` override
* `:causation` — list of `Chronicle.CausationEntry` overrides
"""
@spec append(String.t(), struct(), keyword()) :: :ok | {:error, term()}
defdelegate append(event_source_id, event, opts \\ []), to: Chronicle.EventLog
Expand Down Expand Up @@ -144,4 +152,67 @@ defmodule Chronicle do
"""
@spec all(module(), keyword()) :: {:ok, [struct()]} | {:error, term()}
defdelegate all(model_module, opts \\ []), to: Chronicle.ReadModels

@doc """
Returns all event store names.
"""
@spec get_event_stores(keyword()) :: {:ok, [String.t()]} | {:error, term()}
defdelegate get_event_stores(opts \\ []), to: Chronicle.EventStores, as: :get_all

@doc """
Returns all namespaces for an event store.

Uses the configured client event store by default.
"""
@spec get_namespaces(keyword()) :: {:ok, [String.t()]} | {:error, term()}
defdelegate get_namespaces(opts \\ []), to: Chronicle.EventStores

@doc """
Gets the tail sequence number for an event sequence.
"""
@spec get_tail_sequence_number(String.t() | nil, keyword()) ::
{:ok, non_neg_integer()} | {:error, term()}
defdelegate get_tail_sequence_number(event_source_id \\ nil, opts \\ []), to: Chronicle.EventLog

@doc """
Checks whether there are events for an event source id in an event sequence.
"""
@spec has_events_for?(String.t(), keyword()) :: {:ok, boolean()} | {:error, term()}
defdelegate has_events_for?(event_source_id, opts \\ []), to: Chronicle.EventLog

@doc """
Gets the current process correlation id.
"""
@spec current_correlation_id() :: Chronicle.CorrelationId.t()
defdelegate current_correlation_id(), to: Chronicle.CorrelationIdManager, as: :current

@doc """
Sets the current process correlation id.
"""
@spec set_correlation_id(Chronicle.CorrelationId.t() | String.t()) :: Chronicle.CorrelationId.t()
defdelegate set_correlation_id(correlation_id), to: Chronicle.CorrelationIdManager, as: :set_current

@doc """
Clears the current process correlation id.
"""
@spec clear_correlation_id() :: Chronicle.CorrelationId.t()
defdelegate clear_correlation_id(), to: Chronicle.CorrelationIdManager, as: :clear

@doc """
Gets the current process identity.
"""
@spec current_identity() :: Chronicle.Identity.t()
defdelegate current_identity(), to: Chronicle.IdentityProvider, as: :get_current

@doc """
Sets the current process identity.
"""
@spec set_identity(Chronicle.Identity.t()) :: Chronicle.Identity.t()
defdelegate set_identity(identity), to: Chronicle.IdentityProvider, as: :set_current_identity

@doc """
Clears the current process identity.
"""
@spec clear_identity() :: :ok
defdelegate clear_identity(), to: Chronicle.IdentityProvider, as: :clear_current_identity
end
48 changes: 48 additions & 0 deletions Source/chronicle/lib/chronicle/causation_entry.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Copyright (c) Cratis. All rights reserved.
# Licensed under the MIT license. See LICENSE file in the project root for full license information.

defmodule Chronicle.CausationEntry do
@moduledoc """
Represents a single causation entry in an audit chain.
"""

alias Chronicle.CausationType

@enforce_keys [:occurred, :type]
defstruct [:occurred, :type, properties: %{}]

@type properties :: %{optional(String.t()) => String.t()}
@type t :: %__MODULE__{
occurred: DateTime.t(),
type: CausationType.t(),
properties: properties()
}

@doc """
Creates a new causation entry.
"""
@spec new(CausationType.t() | String.t(), map()) :: t()
def new(type, properties \\ %{}) do
%__MODULE__{
occurred: DateTime.utc_now(),
type: normalize_type(type),
properties: normalize_properties(properties)
}
end

@doc """
Returns a placeholder unknown causation entry.
"""
@spec unknown() :: t()
def unknown, do: new(CausationType.unknown())

defp normalize_type(%CausationType{} = type), do: type
defp normalize_type(type) when is_binary(type), do: CausationType.new(type)
defp normalize_type(type) when is_atom(type), do: CausationType.new(Atom.to_string(type))

defp normalize_properties(properties) when is_map(properties) do
properties
|> Enum.map(fn {k, v} -> {to_string(k), to_string(v)} end)
|> Map.new()
end
end
Loading