Current section

Files

Jump to
scrypath lib mix tasks scrypath index contract_drift.ex
Raw

lib/mix/tasks/scrypath/index/contract_drift.ex

defmodule Mix.Tasks.Scrypath.Index.ContractDrift do
@moduledoc """
Compares a schema's **declared** index contract (fields, filterables, sortables,
faceting, and declared Meilisearch settings families) against the **live** index
via a single read-only Meilisearch `get_settings` call.
## Usage
mix scrypath.index.contract_drift MyApp.Blog.Post
mix scrypath.index.contract_drift MyApp.Blog.Post --json
mix scrypath.index.contract_drift MyApp.Blog.Post --repo MyApp.Repo --index-prefix tenant
## Exit codes
* `0` — comparison completed and every report dimension matches (`match: true`)
* `2` — comparison completed with one or more dimensions `match: false`
* `1` — could not complete (bad args, missing index, network, internal error)
Exit semantics match `mix scrypath.settings.diff`: drift uses exit `2`; runtime
failures use exit `1` via `Mix.raise/2`. The same rules apply with or without `--json`.
## Shell pipelines
Use `set +e` before piping so a contract-drift exit of `2` does not abort the shell
script; branch on `$?` afterward to distinguish parity (`0`) from drift (`2`).
## API
The programmatic entry point is **`Scrypath.index_contract_drift/2`**, which returns
`{:ok, %Scrypath.Operator.IndexContractDrift.Report{}}` or `{:error, reason}`.
"""
@shortdoc "Read-only index contract drift report (delegates to Scrypath.index_contract_drift/2)"
use Mix.Task
alias Scrypath.CLI.OperatorTask
alias Scrypath.Operator.IndexContractDrift.Report
alias Scrypath.Operator.IndexContractDrift.Report.Dimension
@dim_order ~w(fields filterable_attributes sortable_attributes faceting settings)a
@extra_switches [json: :boolean]
@impl true
def run(args) do
Mix.Task.run("app.start")
{opts, argv} = OperatorTask.parse!(args, @extra_switches)
schema = OperatorTask.schema_from_argv!(argv)
config =
Scrypath.Config.resolve!(
OperatorTask.runtime_opts(opts) ++ OperatorTask.test_operator_opts()
)
index = Scrypath.Meilisearch.index_name(schema, config)
case Scrypath.index_contract_drift(schema, config) do
{:ok, %Report{} = report} ->
if report_dimensions_all_match?(report) do
emit_parity!(schema, index, report, opts)
else
emit_drift_and_halt!(schema, report, opts)
end
{:error, reason} ->
OperatorTask.error!("scrypath.index.contract_drift", reason)
end
end
defp report_dimensions_all_match?(%Report{dimensions: dims}) do
Enum.all?(@dim_order, fn key -> Map.fetch!(dims, key).match end)
end
defp emit_parity!(schema, index, report, opts) do
if opts[:json] do
Mix.shell().info(Jason.encode!(report))
else
Mix.shell().info("Index contract OK for #{inspect(schema)} (index=#{index})")
end
end
# `System.halt/1` does not return; Dialyzer reports `no_return` otherwise.
@dialyzer {:nowarn_function, emit_drift_and_halt!: 3}
defp emit_drift_and_halt!(schema, %Report{index: index} = report, opts) do
if opts[:json] do
Mix.shell().info(Jason.encode!(report))
System.halt(2)
else
header = "INDEX CONTRACT DRIFT for #{inspect(schema)} (index=#{index})\n"
body = format_mismatch_lines(report)
footer = drift_footer()
Mix.shell().info(header <> body <> "\n" <> footer <> "\n")
System.halt(2)
end
end
defp drift_footer do
[
"Re-run with --json for the full machine-readable report.",
"For declared-vs-applied Meilisearch settings keys only, see mix scrypath.settings.diff.",
"For operational sync posture and managed reindex flows, see Scrypath.reconcile_sync/2 and Scrypath.reindex/2."
]
|> Enum.join("\n")
end
defp format_mismatch_lines(%Report{dimensions: dims}) do
@dim_order
|> Enum.flat_map(fn key ->
case Map.fetch!(dims, key) do
%Dimension{match: true} ->
[]
%Dimension{match: false, details: details} ->
[" ! #{key}: #{summarize_details(details)}\n"]
end
end)
|> IO.iodata_to_binary()
end
defp summarize_details([]), do: "mismatch (see --json)"
defp summarize_details([%{key: k} | _]) when is_binary(k) or is_atom(k) do
"settings detail key=#{inspect(k)}"
end
defp summarize_details([%{} = first | _]) do
"mismatch #{inspect(Map.keys(first))}"
end
defp summarize_details(details) when is_list(details) do
details |> Enum.take(3) |> Enum.map_join(", ", &inspect/1)
end
end