# `Scrypath`
[🔗](https://github.com/szTheory/scrypath/blob/v0.3.5/lib/scrypath.ex#L1)

Scrypath is the runtime surface for **schema → sync → search**: you declare how records
map into a Meilisearch index, enqueue or inline sync work, then query through the same
Ecto-native boundaries your Phoenix or contexts already own.

## Read next

- [guides/golden-path.md](guides/golden-path.md) — linear first hour from dependencies through a working `Scrypath.search/3`.
- [guides/sync-modes-and-visibility.md](guides/sync-modes-and-visibility.md) — canonical sync modes (`:inline`, `:oban`, `:manual`), eventual consistency, and operator lifecycle language.
- [guides/overview.md](guides/overview.md) — table of contents for every published guide.
- [guides/common-mistakes.md](guides/common-mistakes.md) — evidence-led pitfalls when search and sync feel inconsistent.

## Entry points

- **`sync_record/3`** (and batch variants) — write path and mode semantics; start from
  [guides/sync-modes-and-visibility.md](guides/sync-modes-and-visibility.md).
- **`search/3`** — hydrated search on one schema; follow
  [guides/golden-path.md](guides/golden-path.md) for the first working call.
- **`search_many/2`** — federated multi-schema search; composition rules live in
  [guides/golden-path.md](guides/golden-path.md) and [guides/multi-index-search.md](guides/multi-index-search.md).

Use `use Scrypath` on your Ecto schema; declaration grammar and settings live on
`Scrypath.Schema` — read that module instead of duplicating option tables here.

## Reflection helpers

The initial public reflection surface is intentionally small:

- `schema_config/1`
- `schema_fields/1`
- `schema_settings/1`
- `schema_faceting/1`
- `document_source/1`
- `document_id_field/1`

These functions keep reflection under `Scrypath.*` modules instead of generating
schema-specific runtime verbs.

## Examples

    iex> config = Scrypath.schema_config(SearchablePost)
    iex> config.fields
    [:title, :body]

See also **`search_within_facet/4`** in **`guides/faceted-search-with-phoenix-liveview.md`**
for searching inside a facet bucket alongside `filter:` / `facet_filter:` composition.

# `backfill`

```elixir
@spec backfill(
  module(),
  keyword()
) :: {:ok, map()} | {:error, term()}
```

# `delete_document`

```elixir
@spec delete_document(module(), term(), keyword()) :: {:ok, term()} | {:error, term()}
```

Upserts or deletes search documents through the configured backend.

On success, `{:ok, map}` includes at least **`:mode`** (for example `:inline`, `:oban`, or `:manual`)
and **`:status`**:

* **`:status` `:accepted`** — work was queued or accepted by the backend or queue layer; documents may
  not be queryable yet. This is normal for `:manual`, `:oban`, and sometimes `:inline` when no
  Meilisearch task wait applies. See [guides/sync-modes-and-visibility.md](guides/sync-modes-and-visibility.md).
* **`:status` `:completed`** — the `:inline` path finished, including waiting for a terminal Meilisearch
  task when `sync_mode: :inline` and the backend returned a task handle.

Pitfalls when the database write “succeeded” but search lags: [guides/common-mistakes.md](guides/common-mistakes.md).

Tagged `{:error, reason}` tuples may include `{:timeout, _}`, `{:task_failed, _}`,
`{:invalid_options, _, _}`, and other operational heads. For user-facing copy,
normalize them through your own application boundary instead of depending on internal helpers.

# `delete_documents`

```elixir
@spec delete_documents(module(), [term()], keyword()) ::
  {:ok, term()} | {:error, term()}
```

Upserts or deletes search documents through the configured backend.

On success, `{:ok, map}` includes at least **`:mode`** (for example `:inline`, `:oban`, or `:manual`)
and **`:status`**:

* **`:status` `:accepted`** — work was queued or accepted by the backend or queue layer; documents may
  not be queryable yet. This is normal for `:manual`, `:oban`, and sometimes `:inline` when no
  Meilisearch task wait applies. See [guides/sync-modes-and-visibility.md](guides/sync-modes-and-visibility.md).
* **`:status` `:completed`** — the `:inline` path finished, including waiting for a terminal Meilisearch
  task when `sync_mode: :inline` and the backend returned a task handle.

Pitfalls when the database write “succeeded” but search lags: [guides/common-mistakes.md](guides/common-mistakes.md).

Tagged `{:error, reason}` tuples may include `{:timeout, _}`, `{:task_failed, _}`,
`{:invalid_options, _, _}`, and other operational heads. For user-facing copy,
normalize them through your own application boundary instead of depending on internal helpers.

# `delete_record`

```elixir
@spec delete_record(module(), struct() | map(), keyword()) ::
  {:ok, term()} | {:error, term()}
```

Upserts or deletes search documents through the configured backend.

On success, `{:ok, map}` includes at least **`:mode`** (for example `:inline`, `:oban`, or `:manual`)
and **`:status`**:

* **`:status` `:accepted`** — work was queued or accepted by the backend or queue layer; documents may
  not be queryable yet. This is normal for `:manual`, `:oban`, and sometimes `:inline` when no
  Meilisearch task wait applies. See [guides/sync-modes-and-visibility.md](guides/sync-modes-and-visibility.md).
* **`:status` `:completed`** — the `:inline` path finished, including waiting for a terminal Meilisearch
  task when `sync_mode: :inline` and the backend returned a task handle.

Pitfalls when the database write “succeeded” but search lags: [guides/common-mistakes.md](guides/common-mistakes.md).

Tagged `{:error, reason}` tuples may include `{:timeout, _}`, `{:task_failed, _}`,
`{:invalid_options, _, _}`, and other operational heads. For user-facing copy,
normalize them through your own application boundary instead of depending on internal helpers.

# `document_id_field`

```elixir
@spec document_id_field(module()) :: atom()
```

# `document_source`

```elixir
@spec document_source(module()) :: atom()
```

# `failed_sync_work`

```elixir
@spec failed_sync_work(
  module(),
  keyword()
) ::
  {:ok, [Scrypath.Operator.FailedWork.t()]}
  | {:ok, Scrypath.Operator.FailedSyncWorkInspection.t()}
  | {:error, term()}
```

Returns failed or retrying sync work rows for a schema.

## Reason class rollups

Pass **`reason_class_counts: true`** in operator options (alongside runtime
config) to receive **`{:ok, %Scrypath.Operator.FailedSyncWorkInspection{}}`**
with **`entries`** (the row list) and **`counts`** — a
**`%Scrypath.Operator.ReasonClassCounts{}`** with dense per-class frequencies.
The default remains **`{:ok, [FailedWork.t()]}`** when this option is omitted.

# `index_contract_drift`

```elixir
@spec index_contract_drift(
  module(),
  keyword()
) :: {:ok, Scrypath.Operator.IndexContractDrift.Report.t()} | {:error, term()}
```

Read-only **index contract drift** report for a searchable schema.

Compares declared schema metadata and resolved settings against a single live
Meilisearch `GET /indexes/{uid}/settings` snapshot. This is a **report-first**
operator surface: it does not enqueue work, mutate indexes, or imply recovery
actions. It is **not** the same signal family as `reconcile_sync/2`'s
`drift_signals`, which reflects sync queue and reindex posture.

See `include_index_contract_drift: true` on `reconcile_sync/2` when you want the
same report attached to a reconcile tuple (adds one `get_settings` read).

# `reconcile_sync`

```elixir
@spec reconcile_sync(
  module(),
  keyword()
) :: {:ok, Scrypath.Operator.Reconcile.t()} | {:error, term()} | {:ok, map()}
```

Report-first reconciliation for a schema (sync visibility, failed work, drift
signals, and suggested recovery actions).

## Index contract drift

Pass **`include_index_contract_drift: true`** alongside runtime options to
attach a read-only `%Scrypath.Operator.IndexContractDrift.Report{}` on
**`index_contract_drift`**. This performs an extra Meilisearch **`get_settings`**
read using the same builder as `index_contract_drift/2`. Omit the flag (default)
to avoid that latency and live dependency.

# `reindex`

```elixir
@spec reindex(
  module(),
  keyword()
) :: {:ok, map()} | {:error, term()}
```

# `retry_sync_work`

```elixir
@spec retry_sync_work(
  Scrypath.Operator.FailedWork.t() | Scrypath.Operator.RecoveryAction.t(),
  keyword()
) :: {:ok, map()} | {:error, term()}
```

# `schema_config`

```elixir
@spec schema_config(module()) :: map()
```

# `schema_faceting`

```elixir
@spec schema_faceting(module()) :: keyword()
```

Returns normalized `faceting:` options for the schema, or `[]` when faceting is disabled.

Shape when enabled is a keyword list with `:attributes`, `:max_values_per_facet`, and
`:sort_facet_values_by`.

# `schema_fields`

```elixir
@spec schema_fields(module()) :: [atom()]
```

# `schema_settings`

```elixir
@spec schema_settings(module()) :: map()
```

# `search`

```elixir
@spec search(module(), String.t(), keyword()) :: {:ok, term()} | {:error, term()}
```

Primary hydrated search entry: validates options, resolves runtime config, and
returns `{:ok, search_result}` or tagged `{:error, _}` failures from the
configured backend. Work is wrapped in the public search telemetry span
**`[:scrypath, :search]`** with stable, low-cardinality metadata.

Full pipeline: see [guides/per-query-tuning-pipeline.md](guides/per-query-tuning-pipeline.md).

Optional **`:per_query`** is an allowlisted keyword for Plane B Meilisearch search
parameters on the v1.9 slice (for example `ranking_score_threshold`,
`show_ranking_score`, and `show_ranking_score_details`); semantics and telemetry
expectations are defined in
[guides/per-query-tuning-pipeline.md](guides/per-query-tuning-pipeline.md).

## Errors vs raises

* **`ArgumentError`** — some invalid shapes are rejected synchronously before
  backend dispatch.
* **`{:error, reason}`** — operational failures, including backend errors and
  tuples such as `{:transport_failed, _}`, for callers that want to branch.

`search!/3` raises `Scrypath.Search.Error` with the same `reason` instead of returning
`{:error, _}`.

# `search!`

```elixir
@spec search!(module(), String.t(), keyword()) :: term()
```

Like `search/3`, but returns the same hydrated search result payload or raises
`Scrypath.Search.Error` when the non-bang API would return `{:error, _}`.

# `search_many`

```elixir
@spec search_many(
  list(),
  keyword()
) :: {:ok, term()} | {:error, term()}
```

Full pipeline: see [guides/per-query-tuning-pipeline.md](guides/per-query-tuning-pipeline.md).

**`search_many/2`** merge rules for shared vs per-entry options, `:all` expansion,
and federation payloads remain canonical in **`guides/multi-index-search.md`**.

**`:per_query`** may appear on **shared** keywords and on each per-entry tuple; when
both sides supply it, inner keys shallow-merge with entry bias — see
[guides/per-query-tuning-pipeline.md](guides/per-query-tuning-pipeline.md).

Federated search across multiple schemas.

Entries mirror `search/3` tuples; optional **`federation_weight:`** tunes
Meilisearch merge ordering for that row and requires a backend that implements
`search_many/2`. Invalid weights use `{:invalid_options, {:federation_weight, _}}`;
backends without native multi-search return
`{:invalid_options, {:federation_merge_requires_native_search_many, %{backend: _}}}`.

Successful responses are `{:ok, multi_search_result}` with per-schema search
results. When the backend returns a flat federated `hits` list,
`merge_hit_order` may be populated so callers can inspect merged ordering;
otherwise `merge_hit_order` is `nil`.

See also **`guides/multi-index-search.md`** § **Federation weights**.

## Errors vs raises

* **`{:error, reason}`** — preflight and transport failures you should branch on
  (`{:invalid_options, _}`, `{:validation_failed, _, _}`, `{:transport_failed, _}`, …).
* **`search_many!/2`** raises `Scrypath.Search.Error` with the same `reason` instead of
  returning `{:error, _}`.

# `search_many!`

```elixir
@spec search_many!(
  list(),
  keyword()
) :: term()
```

Like `search_many/2`, but returns `%Scrypath.MultiSearchResult{}` or raises
`Scrypath.Search.Error` when the non-bang API would return `{:error, _}`.

# `search_within_facet`

```elixir
@spec search_within_facet(module(), String.t(), {atom(), term() | list()}, keyword()) ::
  {:ok, term()} | {:error, term()}
```

Full-text search scoped to a single facet bucket, **AND**-combined with any other
`filter:`, `facet_filter:`, sort, and pagination options.

`facet_bucket` is `{facet_attribute, value}` where `value` is either a scalar
(one bucket) or a list interpreted as **OR** within that attribute, matching
`facet_filter:` encoding. Passing the same attribute again under `facet_filter:`
is rejected with `ArgumentError` — use `search/3` or keep only one source of truth.

## Errors vs raises

* **`ArgumentError`** — structural misuse of the facet bucket or duplicate facet locks.
* **`{:error, reason}`** — same operational `{:error, _}` family as `search/3`.

`search_within_facet!/4` raises `Scrypath.Search.Error` when the non-bang call would
return `{:error, _}`.

# `search_within_facet!`

```elixir
@spec search_within_facet!(module(), String.t(), {atom(), term() | list()}, keyword()) ::
  term()
```

# `sync_record`

```elixir
@spec sync_record(module(), struct() | map(), keyword()) ::
  {:ok, term()} | {:error, term()}
```

Upserts or deletes search documents through the configured backend.

On success, `{:ok, map}` includes at least **`:mode`** (for example `:inline`, `:oban`, or `:manual`)
and **`:status`**:

* **`:status` `:accepted`** — work was queued or accepted by the backend or queue layer; documents may
  not be queryable yet. This is normal for `:manual`, `:oban`, and sometimes `:inline` when no
  Meilisearch task wait applies. See [guides/sync-modes-and-visibility.md](guides/sync-modes-and-visibility.md).
* **`:status` `:completed`** — the `:inline` path finished, including waiting for a terminal Meilisearch
  task when `sync_mode: :inline` and the backend returned a task handle.

Pitfalls when the database write “succeeded” but search lags: [guides/common-mistakes.md](guides/common-mistakes.md).

Tagged `{:error, reason}` tuples may include `{:timeout, _}`, `{:task_failed, _}`,
`{:invalid_options, _, _}`, and other operational heads. For user-facing copy,
normalize them through your own application boundary instead of depending on internal helpers.

# `sync_records`

```elixir
@spec sync_records(module(), [struct() | map()], keyword()) ::
  {:ok, term()} | {:error, term()}
```

Upserts or deletes search documents through the configured backend.

On success, `{:ok, map}` includes at least **`:mode`** (for example `:inline`, `:oban`, or `:manual`)
and **`:status`**:

* **`:status` `:accepted`** — work was queued or accepted by the backend or queue layer; documents may
  not be queryable yet. This is normal for `:manual`, `:oban`, and sometimes `:inline` when no
  Meilisearch task wait applies. See [guides/sync-modes-and-visibility.md](guides/sync-modes-and-visibility.md).
* **`:status` `:completed`** — the `:inline` path finished, including waiting for a terminal Meilisearch
  task when `sync_mode: :inline` and the backend returned a task handle.

Pitfalls when the database write “succeeded” but search lags: [guides/common-mistakes.md](guides/common-mistakes.md).

Tagged `{:error, reason}` tuples may include `{:timeout, _}`, `{:task_failed, _}`,
`{:invalid_options, _, _}`, and other operational heads. For user-facing copy,
normalize them through your own application boundary instead of depending on internal helpers.

# `sync_status`

```elixir
@spec sync_status(
  module(),
  keyword()
) :: {:ok, Scrypath.Operator.Status.t()} | {:error, term()}
```

---

*Consult [api-reference.md](api-reference.md) for complete listing*
