lib/chronicle/event_types.ex

# Copyright (c) Cratis. All rights reserved.
# Licensed under the MIT license. See LICENSE file in the project root for full license information.

defmodule Chronicle.EventTypes do
  @moduledoc """
  Registers event types with a Chronicle event store.

  Called automatically by `Chronicle.Projections.Registrar` during startup.
  You can also call it directly to register event types at runtime.

  ## Example

      {:ok, channel} = Chronicle.Connections.Connection.channel(:my_conn)
      :ok = Chronicle.EventTypes.register(channel, "my-store", [MyApp.Events.AccountOpened])
  """

  alias Cratis.Chronicle.Contracts.Events.{
    EventTypes,
    RegisterEventTypesRequest,
    EventTypeRegistration,
    EventType
  }

  @doc """
  Registers a list of event type modules with Chronicle.

  Each module must `use Chronicle.EventType`. Generates a minimal JSON schema
  for each event type based on its struct fields.

  Returns `:ok` on success or `{:error, reason}` on failure.
  """
  @spec register(term(), String.t(), [module()]) :: :ok | {:error, term()}
  def register(_channel, _event_store, []), do: :ok

  def register(channel, event_store, event_type_modules) when is_list(event_type_modules) do
    registrations =
      Enum.map(event_type_modules, fn module ->
        %EventTypeRegistration{
          Type: %EventType{
            Id: module.__chronicle_event_type__(:id),
            Generation: module.__chronicle_event_type__(:generation)
          },
          Schema: generate_schema(module),
          EventStore: event_store
        }
      end)

    request = %RegisterEventTypesRequest{
      EventStore: event_store,
      Types: registrations,
      DisableValidation: false
    }

    case EventTypes.Stub.register(channel, request) do
      {:ok, _} -> :ok
      {:error, reason} -> {:error, reason}
    end
  end

  defp generate_schema(module) do
    fields =
      if function_exported?(module, :__struct__, 0) do
        module.__struct__()
        |> Map.to_list()
        |> Enum.reject(fn {k, _} -> k == :__struct__ end)
        |> Enum.map(fn {key, default_val} ->
          camel_key = key |> Atom.to_string() |> snake_to_camel()
          {camel_key, event_property_schema(default_val)}
        end)
        |> Map.new()
      else
        %{}
      end

    Jason.encode!(%{"type" => "object", "properties" => fields})
  end

  # Use "number" without format: typeFormats.IsKnown(nil) returns false, so
  # ConvertJsonValueFromUnknownFormat is called with type=Number, which calls
  # GetValue<double>() — the only path that works for JsonElement-backed JSON numbers.
  defp event_property_schema(v) when is_integer(v), do: %{"type" => "number"}
  defp event_property_schema(v) when is_float(v), do: %{"type" => "number"}
  defp event_property_schema(v) when is_boolean(v), do: %{"type" => "boolean"}
  defp event_property_schema(_), do: %{"type" => "string"}

  defp snake_to_camel(snake) do
    [head | tail] = String.split(snake, "_")
    head <> Enum.map_join(tail, &String.capitalize/1)
  end
end