Skip to main content

lib/skuld/repo/contract.ex

# Port contract for common Ecto Repo operations.
#
# Mirrors DoubleDown.Repo's defcallback declarations so that skuld's
# effectful Repo surface covers the same operations available through
# plain DoubleDown dispatch.
#
# ## Usage
#
#     alias Skuld.Repo
#
#     comp do
#       {:ok, record} <- Repo.insert(changeset)
#       # ...
#     end
#
# ## Handler Installation
#
#     # Production — delegates to your Ecto Repo
#     defmodule MyApp.Repo.Port do
#       use Skuld.Repo.Ecto, repo: MyApp.Repo
#     end
#
#     comp
#     |> Port.with_handler(%{Port.Repo => MyApp.Repo.Port})
#     |> Comp.run!()
#
#     # Test — in-memory executor with dispatch logging
#     comp
#     |> Port.with_handler(
#       %{Port.Repo => Port.Repo.Test},
#       log: true,
#       output: fn r, state -> {r, state.log} end
#     )
#     |> Comp.run!()
#
defmodule Skuld.Repo.Contract do
  @moduledoc """
  Port contract for common Ecto Repo operations.

  Mirrors `DoubleDown.Repo`'s `defcallback` declarations so that skuld's
  effectful Repo surface covers the same operations available through
  plain DoubleDown dispatch. This enables the upgrade/downgrade path:
  code using `DoubleDown.Repo` via `ContractFacade` can switch to skuld's
  effectful dispatch (or back) without changing the contract.

  ## Write Operations

  Write operations return `{:ok, struct()} | {:error, Ecto.Changeset.t()}`.
  Opts-accepting variants (`insert/2`, `update/2`, `delete/2`) are provided
  for use in `Ecto.Multi` callbacks and similar contexts.

  Bang write variants (`insert!/1,2`, `update!/1,2`, `delete!/1,2`) are
  explicit operations that raise on failure, mirroring `Ecto.Repo`.

  ## Upsert Operations

  `insert_or_update/1,2` and their bang variants delegate to insert or
  update based on whether the changeset's data has `:loaded` state.

  ## Bulk Operations

  `insert_all/3`, `update_all/3` and `delete_all/2` follow Ecto's return
  convention of `{count, nil | list}`.

  ## Read Operations

  Read operations follow Ecto's conventions: `get/2`, `get_by/2`, `one/1`
  return `nil` on not-found; `all/1` returns a list; `exists?/1` returns
  a boolean; `aggregate/3` returns a term.

  Bang read variants (`get!/2`, `get_by!/2`, `one!/1`) are provided as
  separate operations that mirror Ecto's raise-on-not-found semantics.
  In the effectful context these dispatch `Throw` instead of raising.

  ## Transaction Operations

  Transaction operations (`transact`, `transaction`, `rollback`,
  `in_transaction?`) are deliberately omitted from the Repo contract.
  In skuld, transaction coordination is handled by the `Transaction`
  effect (`Skuld.Effects.Transaction`), which provides env state
  rollback, nested savepoints, and optional DB transaction wrapping.

  Similarly, `Ecto.Multi` is not supported — it is a limited
  sequencing mechanism that is superseded by skuld's effect-based
  computation composition (`comp do ... end`).
  """

  use DoubleDown.Contract

  # -----------------------------------------------------------------
  # Write Operations
  # -----------------------------------------------------------------

  @doc "Insert a new record from a changeset or struct."
  defcallback insert(struct_or_changeset :: Ecto.Changeset.t() | struct()) ::
                {:ok, struct()} | {:error, Ecto.Changeset.t()}

  @doc "Insert a new record from a changeset or struct with options."
  defcallback insert(struct_or_changeset :: Ecto.Changeset.t() | struct(), opts :: keyword()) ::
                {:ok, struct()} | {:error, Ecto.Changeset.t()}

  @doc "Update an existing record from a changeset."
  defcallback update(changeset :: Ecto.Changeset.t()) ::
                {:ok, struct()} | {:error, Ecto.Changeset.t()}

  @doc "Update an existing record from a changeset with options."
  defcallback update(changeset :: Ecto.Changeset.t(), opts :: keyword()) ::
                {:ok, struct()} | {:error, Ecto.Changeset.t()}

  @doc "Delete a record or changeset."
  defcallback delete(struct_or_changeset :: struct() | Ecto.Changeset.t()) ::
                {:ok, struct()} | {:error, Ecto.Changeset.t()}

  @doc "Delete a record or changeset with options."
  defcallback delete(struct_or_changeset :: struct() | Ecto.Changeset.t(), opts :: keyword()) ::
                {:ok, struct()} | {:error, Ecto.Changeset.t()}

  # -----------------------------------------------------------------
  # Bang Write Operations
  # -----------------------------------------------------------------

  @doc "Insert a new record, raising on failure. Mirrors `Ecto.Repo.insert!/2`."
  defcallback insert!(struct_or_changeset :: Ecto.Changeset.t() | struct()) :: struct()

  @doc "Insert a new record with options, raising on failure."
  defcallback insert!(struct_or_changeset :: Ecto.Changeset.t() | struct(), opts :: keyword()) ::
                struct()

  @doc "Update an existing record, raising on failure. Mirrors `Ecto.Repo.update!/2`."
  defcallback update!(changeset :: Ecto.Changeset.t()) :: struct()

  @doc "Update an existing record with options, raising on failure."
  defcallback update!(changeset :: Ecto.Changeset.t(), opts :: keyword()) :: struct()

  @doc "Delete a record or changeset, raising on failure. Mirrors `Ecto.Repo.delete!/2`."
  defcallback delete!(struct_or_changeset :: struct() | Ecto.Changeset.t()) :: struct()

  @doc "Delete a record or changeset with options, raising on failure."
  defcallback delete!(struct_or_changeset :: struct() | Ecto.Changeset.t(), opts :: keyword()) ::
                struct()

  # -----------------------------------------------------------------
  # Upsert Operations
  # -----------------------------------------------------------------

  @doc "Insert or update a record depending on whether it has been loaded."
  defcallback insert_or_update(changeset :: Ecto.Changeset.t()) ::
                {:ok, struct()} | {:error, Ecto.Changeset.t()}

  @doc "Insert or update a record with options."
  defcallback insert_or_update(changeset :: Ecto.Changeset.t(), opts :: keyword()) ::
                {:ok, struct()} | {:error, Ecto.Changeset.t()}

  @doc "Insert or update a record, raising on failure."
  defcallback insert_or_update!(changeset :: Ecto.Changeset.t()) :: struct()

  @doc "Insert or update a record with options, raising on failure."
  defcallback insert_or_update!(changeset :: Ecto.Changeset.t(), opts :: keyword()) :: struct()

  # -----------------------------------------------------------------
  # Raw SQL Operations
  # -----------------------------------------------------------------

  @doc "Execute a raw SQL query. Returns `{:ok, result} | {:error, term()}`."
  defcallback query(sql :: String.t()) :: {:ok, term()} | {:error, term()}

  @doc "Execute a raw SQL query with parameters."
  defcallback query(sql :: String.t(), params :: list()) :: {:ok, term()} | {:error, term()}

  @doc "Execute a raw SQL query with parameters and options."
  defcallback query(sql :: String.t(), params :: list(), opts :: keyword()) ::
                {:ok, term()} | {:error, term()}

  @doc "Execute a raw SQL query, raising on error."
  defcallback query!(sql :: String.t()) :: term()

  @doc "Execute a raw SQL query with parameters, raising on error."
  defcallback query!(sql :: String.t(), params :: list()) :: term()

  @doc "Execute a raw SQL query with parameters and options, raising on error."
  defcallback query!(sql :: String.t(), params :: list(), opts :: keyword()) :: term()

  # -----------------------------------------------------------------
  # Bulk Operations
  # -----------------------------------------------------------------

  @doc "Insert all entries into a schema or source at once."
  defcallback insert_all(
                source :: Ecto.Queryable.t() | binary(),
                entries :: [map() | keyword()],
                opts :: keyword()
              ) :: {non_neg_integer(), nil | list()}

  @doc "Update all records matching a queryable."
  defcallback update_all(
                queryable :: Ecto.Queryable.t(),
                updates :: keyword(),
                opts :: keyword()
              ) :: {non_neg_integer(), nil | list()}

  @doc "Delete all records matching a queryable."
  defcallback delete_all(queryable :: Ecto.Queryable.t(), opts :: keyword()) ::
                {non_neg_integer(), nil | list()}

  # -----------------------------------------------------------------
  # Read Operations
  # -----------------------------------------------------------------

  @doc "Fetch a single record by primary key. Returns `nil` if not found."
  defcallback get(queryable :: Ecto.Queryable.t(), id :: term()) :: struct() | nil

  @doc "Fetch a single record by primary key with options."
  defcallback get(queryable :: Ecto.Queryable.t(), id :: term(), opts :: keyword()) ::
                struct() | nil

  @doc """
  Fetch a single record by primary key, or dispatch Throw if not found.

  Mirrors `Ecto.Repo.get!/2` — in the effectful context this dispatches
  `Throw` instead of raising.
  """
  defcallback get!(queryable :: Ecto.Queryable.t(), id :: term()) :: struct()

  @doc "Fetch a single record by primary key with options, or dispatch Throw."
  defcallback get!(queryable :: Ecto.Queryable.t(), id :: term(), opts :: keyword()) :: struct()

  @doc "Fetch a single record by the given clauses. Returns `nil` if not found."
  defcallback get_by(queryable :: Ecto.Queryable.t(), clauses :: keyword() | map()) ::
                struct() | nil

  @doc "Fetch a single record by the given clauses with options."
  defcallback get_by(
                queryable :: Ecto.Queryable.t(),
                clauses :: keyword() | map(),
                opts :: keyword()
              ) :: struct() | nil

  @doc """
  Fetch a single record by the given clauses, or dispatch Throw if not found.

  Mirrors `Ecto.Repo.get_by!/2`.
  """
  defcallback get_by!(queryable :: Ecto.Queryable.t(), clauses :: keyword() | map()) :: struct()

  @doc "Fetch a single record by the given clauses with options, or dispatch Throw."
  defcallback get_by!(
                queryable :: Ecto.Queryable.t(),
                clauses :: keyword() | map(),
                opts :: keyword()
              ) :: struct()

  @doc "Fetch a single result from a query. Returns `nil` if no result."
  defcallback one(queryable :: Ecto.Queryable.t()) :: struct() | nil

  @doc "Fetch a single result from a query with options."
  defcallback one(queryable :: Ecto.Queryable.t(), opts :: keyword()) :: struct() | nil

  @doc """
  Fetch a single result from a query, or dispatch Throw if no result.

  Mirrors `Ecto.Repo.one!/1`.
  """
  defcallback one!(queryable :: Ecto.Queryable.t()) :: struct()

  @doc "Fetch a single result from a query with options, or dispatch Throw."
  defcallback one!(queryable :: Ecto.Queryable.t(), opts :: keyword()) :: struct()

  @doc "Fetch all records matching a queryable."
  defcallback all(queryable :: Ecto.Queryable.t()) :: list(struct())

  @doc "Fetch all records matching a queryable with options."
  defcallback all(queryable :: Ecto.Queryable.t(), opts :: keyword()) :: list(struct())

  @doc "Check whether any record matching the queryable exists."
  defcallback exists?(queryable :: Ecto.Queryable.t()) :: boolean()

  @doc "Check whether any record matching the queryable exists, with options."
  defcallback exists?(queryable :: Ecto.Queryable.t(), opts :: keyword()) :: boolean()

  @doc "Calculate an aggregate over the given field."
  defcallback aggregate(queryable :: Ecto.Queryable.t(), aggregate :: atom(), field :: atom()) ::
                term()

  @doc "Calculate an aggregate over the given field with options."
  defcallback aggregate(
                queryable :: Ecto.Queryable.t(),
                aggregate :: atom(),
                field :: atom(),
                opts :: keyword()
              ) :: term()

  @doc "Fetch all records matching the given clauses."
  defcallback all_by(queryable :: Ecto.Queryable.t(), clauses :: keyword() | map()) ::
                list(struct())

  @doc "Fetch all records matching the given clauses with options."
  defcallback all_by(
                queryable :: Ecto.Queryable.t(),
                clauses :: keyword() | map(),
                opts :: keyword()
              ) :: list(struct())

  # -----------------------------------------------------------------
  # Stream Operations
  # -----------------------------------------------------------------

  @doc "Return a lazy enumerable that emits all records matching a queryable."
  defcallback stream(queryable :: Ecto.Queryable.t()) :: Enum.t()

  @doc "Return a lazy enumerable with options."
  defcallback stream(queryable :: Ecto.Queryable.t(), opts :: keyword()) :: Enum.t()

  # -----------------------------------------------------------------
  # Reload Operations
  # -----------------------------------------------------------------

  @doc "Reload a struct or list of structs from the data store."
  defcallback reload(struct_or_structs :: struct() | list(struct())) ::
                struct() | nil | list(struct() | nil)

  @doc "Reload a struct or list of structs with options."
  defcallback reload(struct_or_structs :: struct() | list(struct()), opts :: keyword()) ::
                struct() | nil | list(struct() | nil)

  @doc "Reload a struct or list of structs, raising if any are not found."
  defcallback reload!(struct_or_structs :: struct() | list(struct())) ::
                struct() | list(struct())

  @doc "Reload a struct or list of structs with options, raising if not found."
  defcallback reload!(struct_or_structs :: struct() | list(struct()), opts :: keyword()) ::
                struct() | list(struct())

  # -----------------------------------------------------------------
  # Preload Operations
  # -----------------------------------------------------------------

  @doc "Preload associations on a struct, list of structs, or nil."
  defcallback preload(
                structs_or_struct_or_nil :: list(struct()) | struct() | nil,
                preloads :: term()
              ) :: list(struct()) | struct() | nil

  @doc "Preload associations with options."
  defcallback preload(
                structs_or_struct_or_nil :: list(struct()) | struct() | nil,
                preloads :: term(),
                opts :: keyword()
              ) :: list(struct()) | struct() | nil

  # -----------------------------------------------------------------
  # Load Operations
  # -----------------------------------------------------------------

  @doc "Load a schema struct or map from raw data. Mirrors `Ecto.Repo.load/2`."
  defcallback load(
                schema_or_map :: module() | map(),
                data :: map() | keyword() | {list(), list()}
              ) :: struct() | map()
end