diff --git a/.github/workflows/cleanup-pr-artifacts.yml b/.github/workflows/cleanup-pr-artifacts.yml index 448a2d3..5f44b3f 100644 --- a/.github/workflows/cleanup-pr-artifacts.yml +++ b/.github/workflows/cleanup-pr-artifacts.yml @@ -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 diff --git a/.github/workflows/propagate-copilot-instructions.yml b/.github/workflows/propagate-copilot-instructions.yml index 1d5909d..8c1f80c 100644 --- a/.github/workflows/propagate-copilot-instructions.yml +++ b/.github/workflows/propagate-copilot-instructions.yml @@ -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 diff --git a/.github/workflows/sync-copilot-instructions.yml b/.github/workflows/sync-copilot-instructions.yml index 974a125..c2e8b59 100644 --- a/.github/workflows/sync-copilot-instructions.yml +++ b/.github/workflows/sync-copilot-instructions.yml @@ -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 diff --git a/.github/workflows/update-packages.yml b/.github/workflows/update-packages.yml index 9126bd7..953b3a6 100644 --- a/.github/workflows/update-packages.yml +++ b/.github/workflows/update-packages.yml @@ -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 diff --git a/README.md b/README.md index 96717d1..c66295d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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. diff --git a/Samples/console/README.md b/Samples/console/README.md index 0438913..ffca04a 100644 --- a/Samples/console/README.md +++ b/Samples/console/README.md @@ -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 @@ -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 @@ -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/ @@ -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 ``` diff --git a/Samples/console/lib/console_sample.ex b/Samples/console/lib/console_sample.ex index 824c483..ea0165c 100644 --- a/Samples/console/lib/console_sample.ex +++ b/Samples/console/lib/console_sample.ex @@ -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: @@ -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...") @@ -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 diff --git a/Samples/console/lib/console_sample/events/account_opened.ex b/Samples/console/lib/console_sample/events/account_opened.ex index a2f4c45..842e5a7 100644 --- a/Samples/console/lib/console_sample/events/account_opened.ex +++ b/Samples/console/lib/console_sample/events/account_opened.ex @@ -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__{ diff --git a/Source/chronicle/lib/chronicle.ex b/Source/chronicle/lib/chronicle.ex index cd04a93..af5b59a 100644 --- a/Source/chronicle/lib/chronicle.ex +++ b/Source/chronicle/lib/chronicle.ex @@ -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 @@ -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 @@ -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 diff --git a/Source/chronicle/lib/chronicle/causation_entry.ex b/Source/chronicle/lib/chronicle/causation_entry.ex new file mode 100644 index 0000000..0d3f980 --- /dev/null +++ b/Source/chronicle/lib/chronicle/causation_entry.ex @@ -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 diff --git a/Source/chronicle/lib/chronicle/causation_manager.ex b/Source/chronicle/lib/chronicle/causation_manager.ex new file mode 100644 index 0000000..7adbe66 --- /dev/null +++ b/Source/chronicle/lib/chronicle/causation_manager.ex @@ -0,0 +1,58 @@ +# Copyright (c) Cratis. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. + +defmodule Chronicle.CausationManager do + @moduledoc """ + Process-scoped causation chain manager. + + Maintains a root causation entry and any additional entries added in the + current process. + """ + + alias Chronicle.{CausationEntry, CausationType} + + @root_key {__MODULE__, :root} + @chain_key {__MODULE__, :chain} + + @doc """ + Gets the current causation chain for the calling process. + """ + @spec get_current_chain() :: [CausationEntry.t()] + def get_current_chain do + root = Process.get(@root_key, default_root()) + chain = Process.get(@chain_key, []) + [root | chain] + end + + @doc """ + Defines the root causation entry for the calling process. + """ + @spec define_root(map()) :: CausationEntry.t() + def define_root(properties \\ %{}) do + root = CausationEntry.new(CausationType.root(), properties) + Process.put(@root_key, root) + root + end + + @doc """ + Adds a causation entry to the current process chain. + """ + @spec add(CausationType.t() | String.t(), map()) :: CausationEntry.t() + def add(type, properties \\ %{}) do + entry = CausationEntry.new(type, properties) + Process.put(@chain_key, Process.get(@chain_key, []) ++ [entry]) + entry + end + + @doc """ + Clears the current process causation root and chain. + """ + @spec clear() :: :ok + def clear do + Process.delete(@root_key) + Process.delete(@chain_key) + :ok + end + + defp default_root, do: CausationEntry.new(CausationType.root(), %{}) +end diff --git a/Source/chronicle/lib/chronicle/causation_type.ex b/Source/chronicle/lib/chronicle/causation_type.ex new file mode 100644 index 0000000..ffaa8ee --- /dev/null +++ b/Source/chronicle/lib/chronicle/causation_type.ex @@ -0,0 +1,43 @@ +# Copyright (c) Cratis. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. + +defmodule Chronicle.CausationType do + @moduledoc """ + Identifies the kind of operation that caused an event append. + """ + + @enforce_keys [:value] + defstruct [:value] + + @type t :: %__MODULE__{value: String.t()} + + @doc """ + Creates a new causation type wrapper. + """ + @spec new(String.t()) :: t() + def new(value) when is_binary(value), do: %__MODULE__{value: value} + + @doc """ + Root causation type. + """ + @spec root() :: t() + def root, do: new("Root") + + @doc """ + Unknown causation type. + """ + @spec unknown() :: t() + def unknown, do: new("Unknown") + + @doc """ + Causation type for single-event append operations. + """ + @spec append_event() :: t() + def append_event, do: new("ElixirClient.Append") + + @doc """ + Causation type for multi-event append operations. + """ + @spec append_many_events() :: t() + def append_many_events, do: new("ElixirClient.AppendMany") +end diff --git a/Source/chronicle/lib/chronicle/constraints.ex b/Source/chronicle/lib/chronicle/constraints.ex index 86d4af0..b0373e8 100644 --- a/Source/chronicle/lib/chronicle/constraints.ex +++ b/Source/chronicle/lib/chronicle/constraints.ex @@ -52,8 +52,7 @@ defmodule Chronicle.Constraints do def register(_channel, _event_store, []), do: :ok def register(channel, event_store, constraints) when is_list(constraints) do - definitions = - Enum.map(constraints, &build_constraint/1) + definitions = Enum.map(constraints, &build_constraint/1) request = struct(RegisterConstraintsRequest, @@ -67,28 +66,108 @@ defmodule Chronicle.Constraints do end end + @doc """ + Builds constraint definitions from model-bound event type attributes. + + Event types can declare: + + * `@unique` / `unique(...)` + * `@unique_event_type` / `unique_event_type(...)` + * `@remove_constraint` / `remove_constraint(...)` + """ + @spec from_event_types([module()]) :: [map()] + def from_event_types(event_types) when is_list(event_types) do + removal_event_types = + event_types + |> Enum.flat_map(fn event_type -> + event_type + |> event_type_constraints() + |> Map.get(:remove_constraint, []) + |> Enum.map(&{normalize_constraint_name(&1), event_type}) + end) + |> Map.new() + + unique_constraints = + event_types + |> Enum.flat_map(fn event_type -> + event_type + |> event_type_constraints() + |> Map.get(:unique, []) + |> Enum.map(&normalize_unique_declaration(&1, event_type)) + end) + |> Enum.group_by(& &1.name) + |> Enum.map(fn {name, definitions} -> + %{ + type: :unique, + name: name, + event_definitions: + Enum.map(definitions, fn definition -> + %{event_type: definition.event_type, on: definition.on} + end) + } + |> with_removed_with_event_type(Map.get(removal_event_types, name)) + end) + + unique_event_type_constraints = + event_types + |> Enum.flat_map(fn event_type -> + event_type + |> event_type_constraints() + |> Map.get(:unique_event_type, []) + |> Enum.map(&normalize_unique_event_type_declaration(&1, event_type)) + end) + |> Enum.map(fn constraint -> + constraint + |> Map.put(:type, :unique_event_type) + |> with_removed_with_event_type(Map.get(removal_event_types, constraint.name)) + end) + + (unique_constraints ++ unique_event_type_constraints) + |> Enum.sort_by(& &1.name) + end + defp build_constraint(%{ type: :unique, name: name, event_type_id: event_type_id, on: properties }) do + build_constraint(%{ + type: :unique, + name: name, + event_definitions: [%{event_type_id: event_type_id, on: properties}] + }) + end + + defp build_constraint(%{type: :unique, name: name, event_definitions: event_definitions} = constraint) do + definition = + struct(OneOf_UniqueConstraintDefinition_UniqueEventTypeConstraintDefinition, + Value0: + struct(UniqueConstraintDefinition, + EventDefinitions: Enum.map(event_definitions, &build_unique_event_definition/1), + IgnoreCasing: false + ) + ) + struct(Constraint, Name: name, Type: :Unique, - Definition: - struct(OneOf_UniqueConstraintDefinition_UniqueEventTypeConstraintDefinition, - Value0: - struct(UniqueConstraintDefinition, - EventDefinitions: [ - struct(UniqueConstraintEventDefinition, - EventTypeId: event_type_id, - Properties: List.wrap(properties) - ) - ], - IgnoreCasing: false - ) - ) + RemovedWith: removed_with_event_type_id(constraint), + Definition: definition + ) + end + + defp build_constraint(%{type: :unique_event_type, name: name, event_type_id: event_type_id} = constraint) do + definition = + struct(OneOf_UniqueConstraintDefinition_UniqueEventTypeConstraintDefinition, + Value1: %{EventTypeId: event_type_id} + ) + + struct(Constraint, + Name: name, + Type: :UniqueEventType, + RemovedWith: removed_with_event_type_id(constraint), + Definition: definition ) end @@ -104,4 +183,92 @@ defmodule Chronicle.Constraints do name: name }) end + + defp build_unique_event_definition(%{event_type_id: event_type_id, on: properties}) do + struct(UniqueConstraintEventDefinition, + EventTypeId: event_type_id, + Properties: List.wrap(properties) + ) + end + + defp build_unique_event_definition(%{event_type: event_type, on: properties}) do + struct(UniqueConstraintEventDefinition, + EventTypeId: event_type.__chronicle_event_type__(:id), + Properties: List.wrap(properties) + ) + end + + defp event_type_constraints(event_type) do + if function_exported?(event_type, :__chronicle_event_type__, 1) do + case event_type.__chronicle_event_type__(:constraints) do + constraints when is_map(constraints) -> constraints + _ -> %{} + end + else + %{} + end + rescue + _ -> %{} + end + + defp normalize_unique_declaration({fields, opts}, event_type) when is_list(opts) do + normalized_fields = normalize_fields(fields) + constraint_name = Keyword.get(opts, :name, default_constraint_name_for_fields(event_type, normalized_fields)) + build_normalized_unique(constraint_name, event_type, normalized_fields) + end + + defp normalize_unique_declaration(opts, event_type) when is_list(opts) do + fields = Keyword.get(opts, :on, Keyword.get(opts, :field, [])) + normalized_fields = normalize_fields(fields) + constraint_name = Keyword.get(opts, :name, default_constraint_name_for_fields(event_type, normalized_fields)) + build_normalized_unique(constraint_name, event_type, normalized_fields) + end + + defp normalize_unique_declaration(fields, event_type) do + normalized_fields = normalize_fields(fields) + build_normalized_unique(default_constraint_name_for_fields(event_type, normalized_fields), event_type, normalized_fields) + end + + defp normalize_unique_event_type_declaration(opts, event_type) when is_list(opts) do + name = normalize_constraint_name(Keyword.get(opts, :name, event_type.__chronicle_event_type__(:id))) + %{name: name, event_type_id: event_type.__chronicle_event_type__(:id)} + end + + defp normalize_unique_event_type_declaration(_opts, event_type) do + name = normalize_constraint_name(event_type.__chronicle_event_type__(:id)) + %{name: name, event_type_id: event_type.__chronicle_event_type__(:id)} + end + + defp normalize_fields(fields) when is_list(fields), do: Enum.map(fields, &to_string/1) + defp normalize_fields(field), do: [to_string(field)] + + defp normalize_constraint_name(name) when is_binary(name), do: name + defp normalize_constraint_name(name) when is_atom(name), do: Atom.to_string(name) + defp normalize_constraint_name(name), do: to_string(name) + + defp default_constraint_name_for_fields(_event_type, [field | _]), do: field + + defp default_constraint_name_for_fields(event_type, []) do + if function_exported?(event_type, :__chronicle_event_type__, 1) do + "#{event_type.__chronicle_event_type__(:id)}-constraint" + else + "#{event_type}-constraint" + end + end + + defp build_normalized_unique(name, event_type, normalized_fields) do + %{name: normalize_constraint_name(name), event_type: event_type, on: normalized_fields} + end + + defp with_removed_with_event_type(definition, nil), do: definition + + defp with_removed_with_event_type(definition, event_type) do + Map.put(definition, :removed_with_event_type, event_type) + end + + defp removed_with_event_type_id(%{removed_with_event_type: event_type}) when not is_nil(event_type) do + event_type.__chronicle_event_type__(:id) + end + + defp removed_with_event_type_id(_), do: "" end diff --git a/Source/chronicle/lib/chronicle/correlation_id.ex b/Source/chronicle/lib/chronicle/correlation_id.ex new file mode 100644 index 0000000..1c01a25 --- /dev/null +++ b/Source/chronicle/lib/chronicle/correlation_id.ex @@ -0,0 +1,54 @@ +# Copyright (c) Cratis. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. + +defmodule Chronicle.CorrelationId do + @moduledoc """ + Represents a correlation identifier for a logical operation. + + Correlation IDs tie together events and side effects that belong to the same + operation (for example a single HTTP request). + """ + + import Bitwise + + @enforce_keys [:value] + defstruct [:value] + + @type t :: %__MODULE__{value: String.t()} + + @not_set "00000000-0000-0000-0000-000000000000" + + @doc """ + Creates a new correlation id wrapper from a string value. + """ + @spec new(String.t()) :: t() + def new(value) when is_binary(value), do: %__MODULE__{value: value} + + @doc """ + Creates a new random UUIDv4 correlation id. + """ + @spec create() :: t() + def create do + <> = :crypto.strong_rand_bytes(16) + c = bor(band(c, 0x0FFF), 0x4000) + d = bor(band(d, 0x3FFF), 0x8000) + + uuid = + [ + Integer.to_string(a, 16) |> String.pad_leading(8, "0"), + Integer.to_string(b, 16) |> String.pad_leading(4, "0"), + Integer.to_string(c, 16) |> String.pad_leading(4, "0"), + Integer.to_string(d, 16) |> String.pad_leading(4, "0"), + Integer.to_string(e, 16) |> String.pad_leading(12, "0") + ] + |> Enum.join("-") + + new(uuid) + end + + @doc """ + Returns the sentinel value used for an explicitly unset correlation id. + """ + @spec not_set() :: t() + def not_set, do: new(@not_set) +end diff --git a/Source/chronicle/lib/chronicle/correlation_id_manager.ex b/Source/chronicle/lib/chronicle/correlation_id_manager.ex new file mode 100644 index 0000000..d9cfbe0 --- /dev/null +++ b/Source/chronicle/lib/chronicle/correlation_id_manager.ex @@ -0,0 +1,51 @@ +# Copyright (c) Cratis. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. + +defmodule Chronicle.CorrelationIdManager do + @moduledoc """ + Process-scoped correlation id manager. + + This provides an idiomatic Elixir alternative to AsyncLocalStorage-based + context in other clients by using the process dictionary. + """ + + alias Chronicle.CorrelationId + + @process_key {__MODULE__, :current} + + @doc """ + Gets the current correlation id for the calling process. + + If no correlation id has been set, a new one is generated and returned. + """ + @spec current() :: CorrelationId.t() + def current do + case Process.get(@process_key) do + %CorrelationId{} = correlation_id -> correlation_id + _ -> CorrelationId.create() + end + end + + @doc """ + Sets the current correlation id for the calling process. + + Accepts either a `%Chronicle.CorrelationId{}` or a raw string id. + """ + @spec set_current(CorrelationId.t() | String.t()) :: CorrelationId.t() + def set_current(%CorrelationId{} = correlation_id) do + Process.put(@process_key, correlation_id) + correlation_id + end + + def set_current(value) when is_binary(value), do: set_current(CorrelationId.new(value)) + + @doc """ + Clears the current correlation id and replaces it with a new generated id. + """ + @spec clear() :: CorrelationId.t() + def clear do + correlation_id = CorrelationId.create() + Process.put(@process_key, correlation_id) + correlation_id + end +end diff --git a/Source/chronicle/lib/chronicle/event_log.ex b/Source/chronicle/lib/chronicle/event_log.ex index 691f9ed..f0454ea 100644 --- a/Source/chronicle/lib/chronicle/event_log.ex +++ b/Source/chronicle/lib/chronicle/event_log.ex @@ -42,6 +42,15 @@ defmodule Chronicle.EventLog do GetForEventSourceIdAndEventTypesRequest } + alias Chronicle.{ + CausationEntry, + CausationManager, + CausationType, + CorrelationId, + CorrelationIdManager, + IdentityProvider + } + alias Chronicle.Connections.Connection # Bcl.Guid used for CorrelationId in AppendRequest @@ -56,11 +65,15 @@ defmodule Chronicle.EventLog do * `:client` — the client name (default: `Chronicle.Client`) * `:namespace` — overrides the client's default namespace + * `:event_sequence_id` — event sequence id (default: `"event-log"`) * `:event_source_type` — the event source type (default: `""`) * `:event_stream_type` — the event stream type (default: `""`) * `:event_stream_id` — the event stream ID (default: `""`) * `:tags` — list of tag strings * `:subject` — the identity subject string + * `:correlation_id` — correlation id override (`Chronicle.CorrelationId` or string) + * `:identity` — identity override (`Chronicle.Identity`) + * `:causation` — causation chain override (list of `Chronicle.CausationEntry`) Returns `:ok` on success or `{:error, reason}` on failure. """ @@ -68,14 +81,15 @@ defmodule Chronicle.EventLog do def append(event_source_id, event, opts \\ []) do with {:ok, channel, config} <- resolve_channel(opts) do namespace = Keyword.get(opts, :namespace, config.namespace) + event_sequence_id = Keyword.get(opts, :event_sequence_id, @event_log_id) event_module = event.__struct__ request = struct(AppendRequest, - CorrelationId: struct(BclGuid), + CorrelationId: build_correlation_id(opts), EventStore: config.event_store, Namespace: namespace, - EventSequenceId: @event_log_id, + EventSequenceId: event_sequence_id, EventSourceType: Keyword.get(opts, :event_source_type, "Default"), EventSourceId: event_source_id, EventStreamType: Keyword.get(opts, :event_stream_type, "All"), @@ -86,13 +100,8 @@ defmodule Chronicle.EventLog do Generation: event_module.__chronicle_event_type__(:generation) ), Content: encode_event(event), - Causation: [client_causation()], - CausedBy: - struct(Identity, - Subject: "elixir-client", - Name: "Chronicle Elixir Client", - UserName: "chronicle" - ), + Causation: build_causation_chain(opts, :append), + CausedBy: build_identity(opts), ConcurrencyScope: struct(ConcurrencyScope, SequenceNumber: 18_446_744_073_709_551_615), Occurred: current_datetime_offset(), Tags: Keyword.get(opts, :tags, []), @@ -124,12 +133,13 @@ defmodule Chronicle.EventLog do ## Options - Same as `append/3`. + Same as `append/3`, including `:event_sequence_id`. """ @spec append_many(String.t(), [struct()], keyword()) :: :ok | {:error, term()} def append_many(event_source_id, events, opts \\ []) when is_list(events) do with {:ok, channel, config} <- resolve_channel(opts) do namespace = Keyword.get(opts, :namespace, config.namespace) + event_sequence_id = Keyword.get(opts, :event_sequence_id, @event_log_id) event_entries = Enum.map(events, fn event -> @@ -154,7 +164,7 @@ defmodule Chronicle.EventLog do struct(AppendManyRequest, EventStore: config.event_store, Namespace: namespace, - EventSequenceId: @event_log_id, + EventSequenceId: event_sequence_id, Events: event_entries ) @@ -182,6 +192,7 @@ defmodule Chronicle.EventLog do * `:client` — the client name (default: `Chronicle.Client`) * `:namespace` — overrides the client's default namespace + * `:event_sequence_id` — event sequence id (default: `"event-log"`) * `:event_types` — list of event type modules to filter by (default: all) Returns `{:ok, [appended_event]}` or `{:error, reason}`. @@ -190,6 +201,7 @@ defmodule Chronicle.EventLog do def get_for_event_source(event_source_id, opts \\ []) do with {:ok, channel, config} <- resolve_channel(opts) do namespace = Keyword.get(opts, :namespace, config.namespace) + event_sequence_id = Keyword.get(opts, :event_sequence_id, @event_log_id) event_type_modules = Keyword.get(opts, :event_types, []) event_types = @@ -204,7 +216,7 @@ defmodule Chronicle.EventLog do struct(GetForEventSourceIdAndEventTypesRequest, EventStore: config.event_store, Namespace: namespace, - EventSequenceId: @event_log_id, + EventSequenceId: event_sequence_id, EventSourceId: event_source_id, EventTypes: event_types ) @@ -216,6 +228,76 @@ defmodule Chronicle.EventLog do end end + @doc """ + Returns the tail sequence number for an event sequence. + + ## Options + + * `:client` — the client name (default: `Chronicle.Client`) + * `:namespace` — overrides the client's default namespace + * `:event_sequence_id` — event sequence id (default: `"event-log"`) + """ + @spec get_tail_sequence_number(String.t() | nil, keyword()) :: {:ok, non_neg_integer()} | {:error, term()} + def get_tail_sequence_number(event_source_id \\ nil, opts \\ []) do + with {:ok, channel, config} <- resolve_channel(opts) do + namespace = Keyword.get(opts, :namespace, config.namespace) + event_sequence_id = Keyword.get(opts, :event_sequence_id, @event_log_id) + + request = %{ + EventStore: config.event_store, + Namespace: namespace, + EventSequenceId: event_sequence_id, + EventSourceId: event_source_id || "", + EventTypes: [], + EventSourceType: "Default", + EventStreamId: "", + EventStreamType: "Default" + } + + case EventSequences.Stub.get_tail_sequence_number(channel, request) do + {:ok, response} -> + sequence_number = Map.get(response, :SequenceNumber, Map.get(response, :sequence_number, 0)) + {:ok, normalize_sequence_number(sequence_number)} + + {:error, reason} -> + {:error, reason} + end + end + end + + @doc """ + Checks whether an event sequence has events for an event source id. + + ## Options + + * `:client` — the client name (default: `Chronicle.Client`) + * `:namespace` — overrides the client's default namespace + * `:event_sequence_id` — event sequence id (default: `"event-log"`) + """ + @spec has_events_for?(String.t(), keyword()) :: {:ok, boolean()} | {:error, term()} + def has_events_for?(event_source_id, opts \\ []) do + with {:ok, channel, config} <- resolve_channel(opts) do + namespace = Keyword.get(opts, :namespace, config.namespace) + event_sequence_id = Keyword.get(opts, :event_sequence_id, @event_log_id) + + request = %{ + EventStore: config.event_store, + Namespace: namespace, + EventSequenceId: event_sequence_id, + EventSourceId: event_source_id + } + + case EventSequences.Stub.has_events_for_event_source_id(channel, request) do + {:ok, response} -> + has_events = Map.get(response, :HasEvents, Map.get(response, :has_events, false)) + {:ok, has_events} + + {:error, reason} -> + {:error, reason} + end + end + end + defp resolve_channel(opts) do client = Keyword.get(opts, :client, Chronicle.Client) @@ -244,9 +326,88 @@ defmodule Chronicle.EventLog do head <> Enum.map_join(tail, &String.capitalize/1) end - defp client_causation do + defp build_correlation_id(opts) do + correlation_id = + case Keyword.get(opts, :correlation_id) do + %CorrelationId{} = id -> id + id when is_binary(id) -> CorrelationId.new(id) + _ -> CorrelationIdManager.current() + end + + guid = struct(BclGuid) + + cond do + Map.has_key?(guid, :Value) -> Map.put(guid, :Value, correlation_id.value) + Map.has_key?(guid, :value) -> Map.put(guid, :value, correlation_id.value) + true -> guid + end + end + + defp build_identity(opts) do + identity = Keyword.get(opts, :identity, IdentityProvider.get_current()) + identity_to_proto(identity) + end + + defp identity_to_proto(identity) do + proto = + struct(Identity, + Subject: identity.subject, + Name: identity.name, + UserName: identity.user_name + ) + + cond do + Map.has_key?(proto, :OnBehalfOf) and not is_nil(identity.on_behalf_of) -> + Map.put(proto, :OnBehalfOf, identity_to_proto(identity.on_behalf_of)) + + Map.has_key?(proto, :on_behalf_of) and not is_nil(identity.on_behalf_of) -> + Map.put(proto, :on_behalf_of, identity_to_proto(identity.on_behalf_of)) + + true -> + proto + end + end + + defp build_causation_chain(opts, mode) do + entries = + case Keyword.get(opts, :causation) do + entries when is_list(entries) and entries != [] -> entries + _ -> CausationManager.get_current_chain() + end + + entries = + case entries do + [] -> + [client_causation_for_mode(mode)] + + _ -> + entries ++ [client_causation_for_mode(mode)] + end + + Enum.map(entries, &causation_to_proto/1) + end + + defp client_causation_for_mode(:append), do: CausationEntry.new(CausationType.append_event()) + defp client_causation_for_mode(:append_many), do: CausationEntry.new(CausationType.append_many_events()) + + defp causation_to_proto(%CausationEntry{} = entry) do + struct(Causation, + Type: entry.type.value, + Occurred: struct(SerializableDateTimeOffset, Value: DateTime.to_iso8601(entry.occurred)) + ) + end + + defp causation_to_proto(entry) when is_map(entry) do + type = + case Map.get(entry, :type) do + %CausationType{value: value} -> value + value when is_binary(value) -> value + value when is_atom(value) -> Atom.to_string(value) + _ -> "Unknown" + end + struct(Causation, - Type: "Elixir.Chronicle.Client", + Type: type, Occurred: current_datetime_offset() ) end @@ -254,4 +415,8 @@ defmodule Chronicle.EventLog do defp current_datetime_offset do struct(SerializableDateTimeOffset, Value: DateTime.utc_now() |> DateTime.to_iso8601()) end + + defp normalize_sequence_number(18_446_744_073_709_551_615), do: 0 + defp normalize_sequence_number(value) when is_integer(value) and value >= 0, do: value + defp normalize_sequence_number(_), do: 0 end diff --git a/Source/chronicle/lib/chronicle/event_stores.ex b/Source/chronicle/lib/chronicle/event_stores.ex new file mode 100644 index 0000000..9be7baf --- /dev/null +++ b/Source/chronicle/lib/chronicle/event_stores.ex @@ -0,0 +1,79 @@ +# Copyright (c) Cratis. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. + +defmodule Chronicle.EventStores do + @moduledoc """ + Lists event stores and namespaces from the Chronicle kernel. + + This mirrors the TypeScript client surface for event-store discovery in an + idiomatic Elixir API. + """ + + alias Cratis.Chronicle.Contracts.{EventStores, Namespaces} + alias Chronicle.Connections.Connection + + @doc """ + Returns all event store names. + + ## Options + + * `:client` — the client name (default: `Chronicle.Client`) + """ + @spec get_all(keyword()) :: {:ok, [String.t()]} | {:error, term()} + def get_all(opts \\ []) do + with {:ok, channel, _config} <- resolve_channel(opts), + {:ok, response} <- EventStores.Stub.get_event_stores(channel, %{}) do + {:ok, items_from_response(response)} + else + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Returns all namespaces for an event store. + + ## Options + + * `:client` — the client name (default: `Chronicle.Client`) + * `:event_store` — event store name (defaults to the configured client event store) + """ + @spec get_namespaces(keyword()) :: {:ok, [String.t()]} | {:error, term()} + def get_namespaces(opts \\ []) do + with {:ok, channel, config} <- resolve_channel(opts), + {:ok, response} <- + Namespaces.Stub.get_namespaces(channel, %{ + EventStore: Keyword.get(opts, :event_store, config.event_store) + }) do + {:ok, items_from_response(response)} + else + {:error, reason} -> {:error, reason} + end + end + + defp resolve_channel(opts) do + client = Keyword.get(opts, :client, Chronicle.Client) + + case Chronicle.Client.config(client) do + config when is_map(config) -> + case Connection.channel(config.connection) do + {:ok, channel} -> {:ok, channel, config} + error -> error + end + + _ -> + {:error, :no_client} + end + end + + defp items_from_response(response) do + response + |> Map.get(:Items, Map.get(response, :items, [])) + |> Enum.map(fn + item when is_binary(item) -> item + %{Name: name} when is_binary(name) -> name + %{name: name} when is_binary(name) -> name + _ -> "" + end) + |> Enum.reject(&(&1 == "")) + end +end diff --git a/Source/chronicle/lib/chronicle/event_type.ex b/Source/chronicle/lib/chronicle/event_type.ex index b6f504d..be0ed16 100644 --- a/Source/chronicle/lib/chronicle/event_type.ex +++ b/Source/chronicle/lib/chronicle/event_type.ex @@ -9,6 +9,24 @@ defmodule Chronicle.EventType do stable event type identifier and generation number. Chronicle uses these to register the event schema and route events to the correct observers. + You can also declare model-bound constraints directly on the event type + through attributes: + + defmodule MyApp.Events.UserRegistered do + use Chronicle.EventType, id: "user-registered-v1" + defstruct [:email] + + @unique :email + unique_event_type() + end + + defmodule MyApp.Events.UserDeleted do + use Chronicle.EventType, id: "user-deleted-v1" + defstruct [:email] + + @remove_constraint "email" + end + ## Usage defmodule MyApp.Events.AccountOpened do @@ -42,9 +60,9 @@ defmodule Chronicle.EventType do @doc """ Returns metadata for this event type module. - Accepts `:id` or `:generation` as the key. + Accepts `:id`, `:generation`, or `:constraints` as the key. """ - @callback __chronicle_event_type__(key :: :id | :generation) :: term() + @callback __chronicle_event_type__(key :: :id | :generation | :constraints) :: term() defmacro __using__(opts) do event_type_id = Keyword.fetch!(opts, :id) @@ -53,12 +71,59 @@ defmodule Chronicle.EventType do quote do @behaviour Chronicle.EventType + Module.register_attribute(__MODULE__, :unique, accumulate: true) + Module.register_attribute(__MODULE__, :remove_constraint, accumulate: true) + Module.register_attribute(__MODULE__, :unique_event_type, accumulate: true) + + import Chronicle.EventType, + only: [unique: 1, unique: 2, unique_event_type: 0, unique_event_type: 1, remove_constraint: 1] + @chronicle_event_type_id unquote(event_type_id) @chronicle_event_type_generation unquote(generation) @impl Chronicle.EventType def __chronicle_event_type__(:id), do: @chronicle_event_type_id def __chronicle_event_type__(:generation), do: @chronicle_event_type_generation + def __chronicle_event_type__(:constraints) do + %{ + unique: Enum.reverse(@unique), + unique_event_type: Enum.reverse(@unique_event_type), + remove_constraint: Enum.reverse(@remove_constraint) + } + end + end + end + + @doc """ + Declares a unique constraint for one or more event fields. + + This is equivalent to setting `@unique`. + """ + defmacro unique(fields, opts \\ []) do + quote do + @unique {unquote(fields), unquote(opts)} + end + end + + @doc """ + Declares a unique constraint for the whole event type. + + This is equivalent to setting `@unique_event_type`. + """ + defmacro unique_event_type(opts \\ []) do + quote do + @unique_event_type unquote(opts) + end + end + + @doc """ + Declares that appending this event removes a named constraint. + + This is equivalent to setting `@remove_constraint`. + """ + defmacro remove_constraint(name) do + quote do + @remove_constraint unquote(name) end end end diff --git a/Source/chronicle/lib/chronicle/identity.ex b/Source/chronicle/lib/chronicle/identity.ex new file mode 100644 index 0000000..91abead --- /dev/null +++ b/Source/chronicle/lib/chronicle/identity.ex @@ -0,0 +1,80 @@ +# Copyright (c) Cratis. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. + +defmodule Chronicle.Identity do + @moduledoc """ + Represents the identity that caused a state change. + """ + + @enforce_keys [:subject, :name] + defstruct [:subject, :name, user_name: "", on_behalf_of: nil] + + @type t :: %__MODULE__{ + subject: String.t(), + name: String.t(), + user_name: String.t(), + on_behalf_of: t() | nil + } + + @system_subject "5d032c92-9d5e-41eb-947a-ee5314ed0032" + @not_set_subject "1efc9b81-0612-4466-962c-86acc4e9a028" + @unknown_subject "3321cf62-db16-425e-8173-99fcfefe11dd" + + @doc """ + Creates a new identity. + """ + @spec new(String.t(), String.t(), String.t(), t() | nil) :: t() + def new(subject, name, user_name \\ "", on_behalf_of \\ nil) do + %__MODULE__{ + subject: subject, + name: name, + user_name: user_name, + on_behalf_of: on_behalf_of + } + end + + @doc """ + Returns the default system identity. + """ + @spec system() :: t() + def system, do: new(@system_subject, "System", "system") + + @doc """ + Returns the sentinel identity for explicitly unset identity values. + """ + @spec not_set() :: t() + def not_set, do: new(@not_set_subject, "Not Set", "not-set") + + @doc """ + Returns the sentinel identity for unknown identity values. + """ + @spec unknown() :: t() + def unknown, do: new(@unknown_subject, "Unknown", "unknown") + + @doc """ + Removes duplicate subjects from an on-behalf-of identity chain. + + Keeps the first occurrence of each subject. + """ + @spec without_duplicates(t()) :: t() + def without_duplicates(%__MODULE__{} = identity) do + {deduped, _seen} = dedupe(identity, MapSet.new()) + deduped + end + + defp dedupe(%__MODULE__{} = identity, seen) do + if MapSet.member?(seen, identity.subject) do + {identity.on_behalf_of, seen} + else + seen = MapSet.put(seen, identity.subject) + + {on_behalf_of, seen} = + case identity.on_behalf_of do + %__MODULE__{} = nested -> dedupe(nested, seen) + _ -> {nil, seen} + end + + {%{identity | on_behalf_of: on_behalf_of}, seen} + end + end +end diff --git a/Source/chronicle/lib/chronicle/identity_provider.ex b/Source/chronicle/lib/chronicle/identity_provider.ex new file mode 100644 index 0000000..0591eed --- /dev/null +++ b/Source/chronicle/lib/chronicle/identity_provider.ex @@ -0,0 +1,44 @@ +# Copyright (c) Cratis. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. + +defmodule Chronicle.IdentityProvider do + @moduledoc """ + Process-scoped identity provider. + + Stores the current identity in the process dictionary so identity flows + naturally through the calling process. + """ + + alias Chronicle.Identity + + @process_key {__MODULE__, :current} + + @doc """ + Gets the current identity for the calling process. + """ + @spec get_current() :: Identity.t() + def get_current do + case Process.get(@process_key) do + %Identity{} = identity -> identity + _ -> Identity.system() + end + end + + @doc """ + Sets the current identity for the calling process. + """ + @spec set_current_identity(Identity.t()) :: Identity.t() + def set_current_identity(%Identity{} = identity) do + Process.put(@process_key, identity) + identity + end + + @doc """ + Clears the current identity for the calling process. + """ + @spec clear_current_identity() :: :ok + def clear_current_identity do + Process.delete(@process_key) + :ok + end +end diff --git a/Source/chronicle/lib/chronicle/projections/registrar.ex b/Source/chronicle/lib/chronicle/projections/registrar.ex index adbd2e2..4685429 100644 --- a/Source/chronicle/lib/chronicle/projections/registrar.ex +++ b/Source/chronicle/lib/chronicle/projections/registrar.ex @@ -13,6 +13,7 @@ defmodule Chronicle.Projections.Registrar do require Logger alias Chronicle.Connections.Connection + alias Chronicle.Constraints alias Chronicle.EventTypes alias Cratis.Chronicle.Contracts.{EventStores, Namespaces, EnsureEventStore, EnsureNamespace} @@ -113,6 +114,7 @@ defmodule Chronicle.Projections.Registrar do with :ok <- ensure_event_store(channel, state.event_store), :ok <- ensure_namespace(channel, state.event_store, state.namespace), :ok <- EventTypes.register(channel, state.event_store, all_event_types), + :ok <- register_constraints(channel, state, all_event_types), :ok <- register_read_models(channel, state), :ok <- register_projections(channel, state) do :ok @@ -136,6 +138,15 @@ defmodule Chronicle.Projections.Registrar do end end + defp register_constraints(channel, state, event_types) do + constraints = Constraints.from_event_types(event_types) + + case Constraints.register(channel, state.event_store, constraints) do + :ok -> :ok + {:error, reason} -> {:error, {:register_constraints, reason}} + end + end + defp register_read_models(channel, state) do projection_definitions = state.read_models diff --git a/Source/chronicle/mix.exs b/Source/chronicle/mix.exs index 1777c9b..76f6c2d 100644 --- a/Source/chronicle/mix.exs +++ b/Source/chronicle/mix.exs @@ -58,7 +58,21 @@ defmodule Chronicle.MixProject do extras: ["README.md"], groups_for_modules: [ Connections: [Chronicle.Connections.ConnectionString, Chronicle.Connections.Connection], - "Event Sourcing": [Chronicle.EventType, Chronicle.EventLog, Chronicle.EventTypes], + Context: [ + Chronicle.CorrelationId, + Chronicle.CorrelationIdManager, + Chronicle.Identity, + Chronicle.IdentityProvider, + Chronicle.CausationType, + Chronicle.CausationEntry, + Chronicle.CausationManager + ], + "Event Sourcing": [ + Chronicle.EventType, + Chronicle.EventLog, + Chronicle.EventTypes, + Chronicle.EventStores + ], Observers: [Chronicle.Reactor, Chronicle.Reducer], "Read Models": [Chronicle.ReadModel, Chronicle.ReadModels], Constraints: [Chronicle.Constraints] diff --git a/Source/chronicle/test/chronicle/causation_manager_test.exs b/Source/chronicle/test/chronicle/causation_manager_test.exs new file mode 100644 index 0000000..19e24e6 --- /dev/null +++ b/Source/chronicle/test/chronicle/causation_manager_test.exs @@ -0,0 +1,40 @@ +# Copyright (c) Cratis. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. + +defmodule Chronicle.CausationManagerTest do + use ExUnit.Case, async: true + + alias Chronicle.{CausationManager, CausationType} + + describe "CausationManager" do + setup do + CausationManager.clear() + :ok + end + + test "provides default root when unset" do + [root] = CausationManager.get_current_chain() + assert root.type.value == CausationType.root().value + end + + test "can define root and append entries" do + root = CausationManager.define_root(%{application: "sample"}) + added = CausationManager.add("MyApp.Command.CreateAccount", %{account_id: "account-1"}) + chain = CausationManager.get_current_chain() + + assert Enum.at(chain, 0).type.value == root.type.value + assert Enum.at(chain, 1).type.value == added.type.value + assert Enum.at(chain, 1).properties["account_id"] == "account-1" + end + + test "clear removes current chain state" do + CausationManager.define_root(%{application: "sample"}) + CausationManager.add("MyApp.Command.Deposit", %{}) + :ok = CausationManager.clear() + + [root] = CausationManager.get_current_chain() + assert root.type.value == CausationType.root().value + assert root.properties == %{} + end + end +end diff --git a/Source/chronicle/test/chronicle/constraints_test.exs b/Source/chronicle/test/chronicle/constraints_test.exs new file mode 100644 index 0000000..d77180c --- /dev/null +++ b/Source/chronicle/test/chronicle/constraints_test.exs @@ -0,0 +1,81 @@ +# Copyright (c) Cratis. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. + +defmodule Chronicle.ConstraintsTest do + use ExUnit.Case, async: true + + defmodule UserRegistered do + use Chronicle.EventType, id: "user-registered-v1" + defstruct [:email] + + @unique :email + end + + defmodule AccountCreated do + use Chronicle.EventType, id: "account-created-v1" + defstruct [:email, :tenant_id] + + unique [:email, :tenant_id], name: "email_per_tenant" + end + + defmodule UserDeleted do + use Chronicle.EventType, id: "user-deleted-v1" + defstruct [:email] + + @remove_constraint "email_per_tenant" + end + + defmodule AccountOpened do + use Chronicle.EventType, id: "account-opened-v1" + defstruct [:account_id] + + unique_event_type() + end + + describe "from_event_types/1" do + test "captures options from unique/2 macro" do + assert AccountCreated.__chronicle_event_type__(:constraints) == %{ + unique: [{[:email, :tenant_id], [name: "email_per_tenant"]}], + unique_event_type: [], + remove_constraint: [] + } + end + + test "builds unique constraints from event type attributes" do + constraints = Chronicle.Constraints.from_event_types([UserRegistered]) + + assert constraints == [ + %{ + type: :unique, + name: "email", + event_definitions: [%{event_type: UserRegistered, on: ["email"]}] + } + ] + end + + test "supports explicit names and removal event mapping" do + constraints = Chronicle.Constraints.from_event_types([AccountCreated, UserDeleted]) + + assert constraints == [ + %{ + type: :unique, + name: "email_per_tenant", + event_definitions: [%{event_type: AccountCreated, on: ["email", "tenant_id"]}], + removed_with_event_type: UserDeleted + } + ] + end + + test "builds unique event type constraints" do + constraints = Chronicle.Constraints.from_event_types([AccountOpened]) + + assert constraints == [ + %{ + type: :unique_event_type, + name: "account-opened-v1", + event_type_id: "account-opened-v1" + } + ] + end + end +end diff --git a/Source/chronicle/test/chronicle/correlation_id_test.exs b/Source/chronicle/test/chronicle/correlation_id_test.exs new file mode 100644 index 0000000..8e60b17 --- /dev/null +++ b/Source/chronicle/test/chronicle/correlation_id_test.exs @@ -0,0 +1,44 @@ +# Copyright (c) Cratis. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. + +defmodule Chronicle.CorrelationIdTest do + use ExUnit.Case, async: true + + alias Chronicle.{CorrelationId, CorrelationIdManager} + + describe "CorrelationId" do + test "create/0 returns UUID-like id" do + %CorrelationId{value: value} = CorrelationId.create() + assert value =~ ~r/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + end + + test "not_set/0 returns zero guid" do + assert CorrelationId.not_set().value == "00000000-0000-0000-0000-000000000000" + end + end + + describe "CorrelationIdManager" do + setup do + Process.delete({CorrelationIdManager, :current}) + :ok + end + + test "returns created id when not set" do + assert %CorrelationId{} = CorrelationIdManager.current() + end + + test "returns set id" do + CorrelationIdManager.set_current("correlation-id") + assert CorrelationIdManager.current().value == "correlation-id" + end + + test "clear sets a fresh id" do + CorrelationIdManager.set_current("existing-id") + cleared = CorrelationIdManager.clear() + + assert %CorrelationId{} = cleared + assert cleared.value != "existing-id" + assert CorrelationIdManager.current().value == cleared.value + end + end +end diff --git a/Source/chronicle/test/chronicle/event_type_test.exs b/Source/chronicle/test/chronicle/event_type_test.exs index 4df23b8..26490c4 100644 --- a/Source/chronicle/test/chronicle/event_type_test.exs +++ b/Source/chronicle/test/chronicle/event_type_test.exs @@ -14,6 +14,15 @@ defmodule Chronicle.EventTypeTest do defstruct [:data] end + defmodule TestEventWithConstraints do + use Chronicle.EventType, id: "test-event-constraints" + defstruct [:email] + + @unique :email + unique_event_type() + @remove_constraint :email + end + describe "use Chronicle.EventType" do test "exposes event type id" do assert TestEvent.__chronicle_event_type__(:id) == "test-event-v1" @@ -30,5 +39,13 @@ defmodule Chronicle.EventTypeTest do test "implements Chronicle.EventType behaviour" do assert function_exported?(TestEvent, :__chronicle_event_type__, 1) end + + test "exposes declared constraints" do + assert TestEventWithConstraints.__chronicle_event_type__(:constraints) == %{ + unique: [:email], + unique_event_type: [[]], + remove_constraint: [:email] + } + end end end diff --git a/Source/chronicle/test/chronicle/identity_test.exs b/Source/chronicle/test/chronicle/identity_test.exs new file mode 100644 index 0000000..0f2eab9 --- /dev/null +++ b/Source/chronicle/test/chronicle/identity_test.exs @@ -0,0 +1,67 @@ +# Copyright (c) Cratis. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. + +defmodule Chronicle.IdentityTest do + use ExUnit.Case, async: true + + alias Chronicle.{Identity, IdentityProvider} + + describe "Identity" do + test "new/4 builds identity" do + identity = Identity.new("user-1", "Alice", "alice") + + assert identity.subject == "user-1" + assert identity.name == "Alice" + assert identity.user_name == "alice" + end + + test "well-known identities have expected sentinel subjects" do + assert Identity.system().subject == "5d032c92-9d5e-41eb-947a-ee5314ed0032" + assert Identity.not_set().subject == "1efc9b81-0612-4466-962c-86acc4e9a028" + assert Identity.unknown().subject == "3321cf62-db16-425e-8173-99fcfefe11dd" + end + + test "without_duplicates removes duplicate subjects from chain" do + identity = + Identity.new( + "service-a", + "Service A", + "service-a", + Identity.new( + "service-a", + "Service A Duplicate", + "service-a", + Identity.new("user-1", "User", "user") + ) + ) + + deduped = Identity.without_duplicates(identity) + + assert deduped.subject == "service-a" + assert deduped.on_behalf_of.subject == "user-1" + assert deduped.on_behalf_of.on_behalf_of == nil + end + end + + describe "IdentityProvider" do + setup do + Process.delete({IdentityProvider, :current}) + :ok + end + + test "returns system identity when no identity set" do + assert IdentityProvider.get_current().subject == Identity.system().subject + end + + test "can set and clear current identity" do + identity = Identity.new("user-2", "Bob", "bob") + IdentityProvider.set_current_identity(identity) + + assert IdentityProvider.get_current().subject == "user-2" + + :ok = IdentityProvider.clear_current_identity() + + assert IdentityProvider.get_current().subject == Identity.system().subject + end + end +end