Skip to main content

lib/terminus_db/commit.ex

defmodule TerminusDB.Commit do
  @moduledoc """
  Commit history and inspection API for TerminusDB.

  Every write to TerminusDB creates an immutable commit. Commits form a chain
  (per branch) that gives the full history of the database. This module wraps
  the history/log endpoints to traverse and inspect commits.

  All functions require a `TerminusDB.Config` scoped to a database (via
  `TerminusDB.Config.with_database/2`). The branch defaults to `config.branch`
  but can be overridden per call via the `:branch` option.

  ## Quick start

      config =
        TerminusDB.Config.new(endpoint: "http://localhost:6363")
        |> TerminusDB.Config.with_database("mydb")

      # List recent commits on the current branch
      {:ok, log} = TerminusDB.Commit.log(config)

      # Full history with commit metadata
      {:ok, history} = TerminusDB.Commit.history(config)

      # Inspect a specific commit
      {:ok, commit} = TerminusDB.Commit.get(config, "commit/abc123")

  """

  alias TerminusDB.{Client, Config, Error}
  alias TerminusDB.Client.Params

  @type commit_opt ::
          {:branch, String.t()}
          | {:repo, String.t()}
          | {:organization, String.t()}
          | {:start, String.t()}
          | {:limit, pos_integer()}
          | {:count, pos_integer()}

  @type history_opt ::
          {:id, String.t()}
          | {:start, non_neg_integer()}
          | {:count, pos_integer()}
          | {:created, boolean()}
          | {:updated, boolean()}
          | {:organization, String.t()}

  defp commit_path(config, opts, resource) do
    org = opts[:organization] || config.organization
    db = config.database || raise Error, reason: :http, message: "no database scoped in config"
    repo = opts[:repo] || config.repo
    branch = opts[:branch] || config.branch
    "#{resource}/#{org}/#{db}/#{repo}/branch/#{branch}"
  end

  @doc """
  Returns a concise log of recent commits on the current (or given) branch.

  Each entry includes the commit ID, author, message, and timestamp.

  ## Options

  - `:branch` - overrides `config.branch`.
  - `:repo` - overrides `config.repo`.
  - `:organization` - overrides `config.organization`.
  - `:start` - a commit ID to start listing from (for pagination).
  - `:limit` - max number of commits to return.

  ## Examples

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req ->
      ...>     {req, Req.Response.new(status: 200, body: [
      ...>       %{"commit" => "commit/abc", "author" => "admin", "message" => "add Alice", "timestamp" => "2026-06-24T10:00:00Z"}
      ...>     ])}
      ...>   end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> {:ok, log} = TerminusDB.Commit.log(config)
      iex> hd(log)["author"]
      "admin"

  """
  @spec log(Config.t(), [commit_opt()]) :: {:ok, [map()]} | {:error, Error.t()}
  def log(config, opts \\ []) do
    path = commit_path(config, opts, "log")

    params =
      Params.flag_param(:start, opts[:start]) ++
        Params.flag_param(:limit, opts[:limit]) ++
        Params.flag_param(:count, opts[:count])

    Client.request(config, :get, path, params: params, area: :commit)
  end

  @doc """
  Returns a concise log of commits, or raises `TerminusDB.Error`.

  ## Examples

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: [%{"commit" => "c1"}])} end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> TerminusDB.Commit.log!(config)
      [%{"commit" => "c1"}]

  """
  @spec log!(Config.t(), [commit_opt()]) :: [map()]
  def log!(config, opts \\ []) do
    case log(config, opts) do
      {:ok, body} -> body
      {:error, error} -> raise error
    end
  end

  @doc """
  Returns the full commit history for the current (or given) branch.

  This is an alias for `log/2` - both use the same `/api/log` endpoint with
  the same parameters. Provided as a semantically distinct name for callers
  building history viewers or audit trails.

  ## Options

  Same as `log/2`.

  ## Examples

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req ->
      ...>     {req, Req.Response.new(status: 200, body: [
      ...>       %{"@id" => "commit/abc", "author" => "admin", "message" => "init", "timestamp" => 1782350430.12}
      ...>     ])}
      ...>   end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> {:ok, history} = TerminusDB.Commit.history(config)
      iex> is_list(history)
      true

  """
  @spec history(Config.t(), [commit_opt()]) :: {:ok, [map()]} | {:error, Error.t()}
  def history(config, opts \\ []) do
    log(config, opts)
  end

  @doc """
  Returns the full commit history, or raises `TerminusDB.Error`.

  ## Examples

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: [%{"commit" => "c1"}])} end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> TerminusDB.Commit.history!(config)
      [%{"commit" => "c1"}]

  """
  @spec history!(Config.t(), [commit_opt()]) :: [map()]
  def history!(config, opts \\ []) do
    case history(config, opts) do
      {:ok, body} -> body
      {:error, error} -> raise error
    end
  end

  @doc """
  Retrieves a single commit by its descriptor ID (e.g. `"commit/abc123"`).

  Returns the commit object with its metadata: author, message, timestamp,
  parent reference, and the schema/data references.

  ## Options

  - `:branch` - overrides `config.branch`.
  - `:repo` - overrides `config.repo`.
  - `:organization` - overrides `config.organization`.

  ## Examples

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req ->
      ...>     {req, Req.Response.new(status: 200, body: %{"@id" => "commit/abc", "author" => "admin", "message" => "add Alice"}}
      ...>   end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> {:ok, commit} = TerminusDB.Commit.get(config, "commit/abc")
      iex> commit["author"]
      "admin"

  """
  @spec get(Config.t(), String.t(), [commit_opt()]) :: {:ok, map()} | {:error, Error.t()}
  def get(config, commit_id, opts \\ []) do
    org = opts[:organization] || config.organization

    db =
      config.database ||
        raise Error, reason: :http, message: "no database scoped in config"

    repo = opts[:repo] || config.repo
    branch = opts[:branch] || config.branch
    path = "history/#{org}/#{db}/#{repo}/branch/#{branch}/#{commit_id}"

    Client.request(config, :get, path, area: :commit)
  end

  @doc """
  Retrieves a single commit, or raises `TerminusDB.Error`.

  ## Examples

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"@id" => "commit/abc", "author" => "admin"}} end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> TerminusDB.Commit.get!(config, "commit/abc")
      %{"@id" => "commit/abc", "author" => "admin"}

  """
  @spec get!(Config.t(), String.t(), [commit_opt()]) :: map()
  def get!(config, commit_id, opts \\ []) do
    case get(config, commit_id, opts) do
      {:ok, body} -> body
      {:error, error} -> raise error
    end
  end

  @doc """
  Retrieves the commit history for a specific document.

  Returns the history of changes made to a document, ordered backwards in
  time from the most recent change.

  ## Options

  - `:id` (required) - the document ID (IRI) to retrieve history for.
  - `:start` - starting index for pagination (default `0`).
  - `:count` - maximum number of entries (default `10`).
  - `:created` - if `true`, return only the creation time.
  - `:updated` - if `true`, return only the last update time.
  - `:organization` - overrides `config.organization`.

  ## Examples

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: [%{"author" => "admin", "identifier" => "abc", "message" => "Created"}])} end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> {:ok, history} = TerminusDB.Commit.document_history(config, id: "Person/Alice", count: 5)
      iex> length(history)
      1

  """
  @spec document_history(Config.t(), [history_opt()]) ::
          {:ok, [map()]} | {:error, Error.t()}
  def document_history(config, opts \\ []) do
    org = opts[:organization] || config.organization
    db = config.database || raise Error, reason: :http, message: "no database scoped in config"

    params =
      [
        id: Keyword.fetch!(opts, :id),
        start: opts[:start] || 0,
        count: opts[:count] || 10
      ]
      |> maybe_add_flag(:created, opts[:created])
      |> maybe_add_flag(:updated, opts[:updated])

    Client.request(config, :get, "history/#{org}/#{db}", params: params, area: :commit)
  end

  @doc """
  Retrieves document commit history, or raises.

  ## Examples

      iex> config = TerminusDB.Config.new(
      ...>   endpoint: "http://localhost:6363",
      ...>   adapter: fn req -> {req, Req.Response.new(status: 200, body: [%{"author" => "admin"}])} end
      ...> ) |> TerminusDB.Config.with_database("mydb")
      iex> TerminusDB.Commit.document_history!(config, id: "Person/Alice")
      [%{"author" => "admin"}]

  """
  @spec document_history!(Config.t(), [history_opt()]) :: [map()]
  def document_history!(config, opts \\ []) do
    case document_history(config, opts) do
      {:ok, body} -> body
      {:error, error} -> raise error
    end
  end

  defp maybe_add_flag(params, _key, nil), do: params
  defp maybe_add_flag(params, key, value), do: Keyword.put(params, key, value)
end