Scrypath gives Phoenix and Ecto teams an explicit path for declaring searchable schemas, syncing search documents, and querying through one context-owned boundary.

What You Set Up

Start with three pieces:

  1. A schema that declares search metadata with use Scrypath
  2. A context that owns repo persistence plus Scrypath.* orchestration
  3. A backend configuration that keeps sync mode explicit, plus Oban only if you want queued sync

Declare A Searchable Schema

defmodule MyApp.Blog.Post do
  use Ecto.Schema

  use Scrypath,
    fields: [:title, :body],
    filterable: [:status],
    sortable: [:inserted_at]

  schema "posts" do
    field :title, :string
    field :body, :string
    field :status, Ecto.Enum, values: [:draft, :published]
    timestamps()
  end
end

use Scrypath stays metadata-only. Runtime orchestration still lives in your context modules. Scrypath also owns its internal transport dependency, so the base install path stays focused on adding :scrypath.

Put Search And Sync In The Context

defmodule MyApp.Content do
  alias MyApp.Blog.Post
  alias MyApp.Repo

  def search_posts(query, opts \\ []) do
    Scrypath.search(Post, query,
      Keyword.merge([backend: Scrypath.Meilisearch, repo: Repo], opts)
    )
  end

  def publish_post(post, attrs) do
    with {:ok, post} <- update_post(post, attrs),
         {:ok, _sync} <-
           Scrypath.sync_record(Post, post,
             backend: Scrypath.Meilisearch,
             sync_mode: :inline
           ) do
      {:ok, post}
    end
  end
end

That keeps reads, writes, sync decisions, and failure handling in one application boundary instead of spreading them across controllers or LiveView callbacks.

Keep Web Modules Thin

Controllers and LiveView modules call the same context boundary:

defmodule MyAppWeb.PostController do
  use MyAppWeb, :controller

  alias MyApp.Content

  def index(conn, params) do
    {:ok, result} =
      Content.search_posts(Map.get(params, "q", ""),
        filter: [status: "published"]
      )

    render(conn, :index, posts: result.records, search: result)
  end
end
defmodule MyAppWeb.PostLive do
  use MyAppWeb, :live_view

  alias MyApp.Content

  def handle_params(%{"q" => query}, _uri, socket) do
    {:ok, result} = Content.search_posts(query, preload: [:author])

    {:noreply, assign(socket, posts: result.records, search: result, query: query)}
  end
end

Choose Sync Mode Deliberately

  • :inline waits for terminal backend success before returning
  • :manual returns accepted backend work immediately for imports and operator-driven flows
  • :oban returns durable enqueue acceptance only and is an optional production path when you want queued sync

Accepted work is not the same thing as search visibility. Pick the mode that matches your consistency and operational constraints.

Continue