Skip to main content

lib/dust_ecto/schema.ex

defmodule DustEcto.Schema do
  @moduledoc """
  `use DustEcto.Schema, prefix: ["links"], required: [:slug, :title]`
  pairs an `Ecto.Schema` (embedded) with a Dust prefix and the slug
  field used as the per-record namespace key.

  ## Usage

      defmodule MyApp.Reading.Link do
        use DustEcto.Schema,
          prefix: ["links"],                # required: segment list
          required: [:slug, :title, :url],  # used by changeset + Repo.all guard
          mode: :flat                       # :flat (default) | :map

        embedded_schema do
          field :title, :string
          field :url, :string
          field :note, :string
        end

        def changeset(link, attrs) do
          link
          |> cast(attrs, [:slug, :title, :url, :note])
          |> validate_required(__dust_required_fields__())
          |> validate_dust_slug(:slug)
        end
      end

  Multi-segment prefixes are a non-empty list of segments. Earlier
  versions accepted a dotted string (`"reading.links"`); after the
  segment-first migration, this must be an explicit list
  (`["reading", "links"]`) so dots in segments survive intact.

  ## Storage modes

  Pick `:flat` (the default) unless you know you want `:map`.

  **`:flat` (default)** — one PUT per field; record lives on the wire as
  N leaves at `<prefix>/<slug>/<field>` (canonical slash form). This
  is the natural Dust shape: other writers (MCP, curl, sibling clients)
  can edit a single field without knowing the rest of the record, and
  per-field subscriptions are granular. Cost: writes are not atomic
  across fields; a partial update is observable until the last PUT
  lands.

  **`:map`** — one PUT for the whole record at `<prefix>/<slug>`, with
  the dumped struct as the value. Atomic, single revision per record.
  Cost: writes from outside the schema (a curl that PUTs
  `links/foo/title` directly) race with the next `:map` write that
  clobbers the whole record. Use when *you are the only writer* and you
  need whole-record atomicity.

  Reads work identically in both modes — Dust stores everything as flat
  leaves on disk, and `Repo.get/2` GETs the slug path which the server
  assembles back to a map.

  ## What the macro provides

  - `use Ecto.Schema` + `import Ecto.Changeset`
  - `@primary_key {:slug, :string, autogenerate: false}`
  - `__dust_prefix__/0` — the prefix segment list (`["reading", "links"]`)
  - `__dust_mode__/0` — `:flat` (default) or `:map`
  - `__dust_required_fields__/0` — the `:required` list, used by both
    the user's `validate_required` *and* `DustEcto.Repo.all/1`'s
    read-time guard so they stay in sync. Necessary because Ecto's
    `validate_required` is a runtime check with no introspectable
    metadata.
  - `validate_dust_slug/2` — closes path-shape footguns by rejecting
    empty slugs, slugs containing `.` (would mis-shape a *legacy*
    record path; harmless under segment-first storage but still
    rejected for clarity), and slugs containing `/` (would conflict
    with the canonical slash separator).
  """

  defmacro __using__(opts) do
    prefix = Keyword.fetch!(opts, :prefix)
    required = Keyword.get(opts, :required, [])
    mode = Keyword.get(opts, :mode, :flat)

    case validate_prefix(prefix) do
      :ok ->
        :ok

      {:error, reason} ->
        raise ArgumentError, reason
    end

    unless mode in [:map, :flat] do
      raise ArgumentError,
            "DustEcto.Schema :mode must be :map or :flat (got #{inspect(mode)})"
    end

    unless is_list(required) and Enum.all?(required, &is_atom/1) do
      raise ArgumentError,
            "DustEcto.Schema :required must be a list of field atoms (got #{inspect(required)})"
    end

    quote do
      use Ecto.Schema
      import Ecto.Changeset
      import DustEcto.Schema, only: [validate_dust_slug: 2]

      @primary_key {:slug, :string, autogenerate: false}

      def __dust_prefix__, do: unquote(prefix)
      def __dust_mode__, do: unquote(mode)
      def __dust_required_fields__, do: unquote(required)

      @doc """
      All field names declared in `embedded_schema`, including the
      `:slug` primary key. Used by `DustEcto.Repo` for read-time
      validation and flat-mode writes.
      """
      def __dust_field_names__, do: __schema__(:fields)
    end
  end

  # Validate the `:prefix` schema option. Returns `:ok` for a
  # non-empty list of non-empty binaries; otherwise an `{:error,
  # reason}` tuple with a migration-friendly message that points
  # legacy dotted-string callers at the new contract.
  @doc false
  def validate_prefix(prefix) do
    cond do
      is_binary(prefix) ->
        suggested =
          case prefix do
            "" -> "[]"
            s when is_binary(s) -> inspect(String.split(s, "."))
          end

        {:error,
         "DustEcto.Schema :prefix is now a segment list, not a string. " <>
           "Replace `prefix: #{inspect(prefix)}` with `prefix: #{suggested}`. " <>
           "(Segment-first paths landed in capver 3; see " <>
           "docs/plans/2026-05-12-segment-first-paths.md.)"}

      not is_list(prefix) ->
        {:error,
         "DustEcto.Schema :prefix must be a non-empty list of non-empty strings (got #{inspect(prefix)})"}

      prefix == [] ->
        {:error, "DustEcto.Schema :prefix must not be empty"}

      not Enum.all?(prefix, &(is_binary(&1) and &1 != "")) ->
        {:error,
         "DustEcto.Schema :prefix segments must be non-empty strings (got #{inspect(prefix)})"}

      true ->
        :ok
    end
  end

  @doc """
  Validates that the given slug field is a non-empty string.

  In capver 3 (segment-first paths), the slug is one path segment.
  Literal `.` and `/` inside that segment are ordinary characters:
  the dot is just a dot, and the slash is rendered as `~1` per RFC
  6901 before hitting any wire / SQL key. So this validator only
  rejects what's structurally invalid — empty strings and non-strings.

  Use inside any `changeset/2`:

      |> validate_dust_slug(:slug)
  """
  @spec validate_dust_slug(Ecto.Changeset.t(), atom()) :: Ecto.Changeset.t()
  def validate_dust_slug(%Ecto.Changeset{} = changeset, field) when is_atom(field) do
    case Ecto.Changeset.get_field(changeset, field) do
      nil ->
        # Required-ness is enforced separately by validate_required; we
        # only validate the *shape* of a present slug here.
        changeset

      "" ->
        Ecto.Changeset.add_error(changeset, field, "cannot be empty")

      slug when is_binary(slug) ->
        changeset

      _other ->
        Ecto.Changeset.add_error(changeset, field, "must be a string")
    end
  end
end