# `PhoenixKit.Migrations.Repair.Probe`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v2.13.7/lib/phoenix_kit/migrations/repair/probe.ex#L1)

All read-only introspection against the **target** server: the raw version
comment (task rule (a): never `PhoenixKit.Migrations.Postgres.migrated_version/1`'s
legacy no-comment→1 mapping — see below), a structural catalog snapshot
mirroring `PhoenixKit.Squash.Generate.Catalog`'s queries (`dev_docs/squash/
generate_baseline.exs`), and the small pure helpers that read an
`PhoenixKit.Migrations.ExpectedSchema.Object.t()`'s `check` field against
that snapshot.

Every function that takes a `repo` executes real queries and has no unit
test (this repo's suite is DB-free); `lookup/2` and `presence_by_since/2`
take an already-fetched snapshot and are pure — those are the ones
`test/phoenix_kit/migrations/repair/probe_test.exs` exercises directly with
hand-built snapshots.

## Raw comment read — why this duplicates `Postgres.migrated_version/1`'s SQL instead of calling it

`Postgres.migrated_version/1` (`postgres.ex:1373-1410`) runs the exact same
two queries this module's `raw_comment/2` does (table-exists, then
`obj_description`), but its **last clause folds "table exists, no comment"
into `1`** ("Table exists but no version comment - assume version 1
(legacy V01 installation)") — the right behavior for a migration entry
point deciding what to run next, and exactly the wrong behavior for
`PhoenixKit.Migrations.Repair`: spec §6.4 R4 needs to tell "genuinely at
V1" apart from "half-installed/adopted, comment stripped" (§6.1: "that
mapping would swallow the half-installed/adopted case into the below-floor
error"). There is no way to get the un-folded value out of
`migrated_version/1` — its `case` has no branch that returns it — so
`raw_comment/2` re-implements the same two queries and stops one line
earlier.

# `raw_comment`

```elixir
@type raw_comment() :: :absent | nil | :unparseable | non_neg_integer()
```

`:absent` (table missing), `nil` (table exists, no comment), or the numeric comment.

# `snapshot`

```elixir
@type snapshot() :: %{
  tables: %{required(String.t()) =&gt; map()},
  columns: %{required({String.t(), String.t()}) =&gt; map()},
  indexes: %{required(String.t()) =&gt; map()},
  constraints: %{required({String.t(), String.t()}) =&gt; map()},
  sequences: %{required(String.t()) =&gt; map()},
  functions: %{required({String.t(), String.t()}) =&gt; map()},
  extensions: %{required(String.t()) =&gt; map()}
}
```

One schema's structural snapshot — mirrors `PhoenixKit.Squash.Generate.Catalog.snapshot/2`'s shape, minus seeds (see moduledoc "Seeds" note on `PhoenixKit.Migrations.Repair.Differ`).

# `lookup`

```elixir
@spec lookup(snapshot(), PhoenixKit.Migrations.ExpectedSchema.Object.check()) ::
  map() | nil
```

Looks up the observed shape for one `{:catalog, spec}` check within a
`snapshot/2` result. `nil` means absent (the caller treats that as
`:missing`). Pure — the one function in this module with a direct unit
test (`probe_test.exs` builds a snapshot by hand).

    iex> snapshot = %{tables: %{"widgets" => %{}}, columns: %{}, indexes: %{},
    ...>   constraints: %{}, sequences: %{}, functions: %{}, extensions: %{}}
    iex> Probe.lookup(snapshot, {:catalog, %{kind: :table, name: "widgets"}})
    %{}
    iex> Probe.lookup(snapshot, {:catalog, %{kind: :table, name: "missing"}})
    nil

# `orphan_count`

```elixir
@spec orphan_count(
  Ecto.Repo.t(),
  String.t(),
  String.t(),
  String.t(),
  String.t(),
  String.t()
) ::
  non_neg_integer() | :unknown
```

Counts rows in `table` whose `fk_column` does not match any row in
`foreign_table.foreign_column` — the orphan diagnostic spec §6.3 attaches
to a failed FK `VALIDATE CONSTRAINT` (leave the constraint `NOT VALID`,
report the count rather than guessing a fix). All four identifiers are
validated (`[a-zA-Z_][a-zA-Z0-9_]*`) before interpolation — they come from
the catalog snapshot, not user input, but this executes as a plain string
query, not a parameterized one, because table/column names cannot be bind
parameters in SQL.

# `presence_by_since`

```elixir
@spec presence_by_since([PhoenixKit.Migrations.ExpectedSchema.Object.t()], snapshot()) ::
  [
    {pos_integer(), boolean()}
  ]
```

For each distinct `since` among `objects`, whether every object introduced
at that version is structurally present in `snapshot` — the input
`PhoenixKit.Migrations.Repair.CommentPolicy.highest_fully_present_version/1`
consumes. `:seed`-class and `:legacy_optional` objects are excluded from
the computation (never made to count against a version being "fully
present"): seed presence is a data-completeness question, not a schema
marker (best-effort seeders are "NOT guaranteed present" by design), and
`:legacy_optional` presence is bimodal by design (spec §3.7) — either state
is normal, so neither should ever cause a version to read as "behind".

Grouping happens on the RAW `since` first, filtering only within each
group — never `Enum.filter/2` before `Enum.group_by/2`. A `since` bucket
whose objects are *entirely* seeds/`:legacy_optional` (e.g. a version that
only ever added a seed row) still gets an entry here, `{since, true}`
(`Enum.all?/2` on the empty, fully-filtered list is vacuously `true`) —
dropping such a bucket from the result instead of reporting it present
would happen to be harmless for `CommentPolicy.highest_fully_present_version/1`
today (`take_while` over a sorted list tolerates a missing entry exactly
like a `true` one), but this function's own contract — "for each distinct
`since` among `objects`" — promises an entry for every one, and a future
caller should not have to know that gap-tolerance to rely on it correctly.

# `raw_comment`

```elixir
@spec raw_comment(Ecto.Repo.t(), String.t()) :: raw_comment()
```

The raw version comment — see moduledoc. Never raises; any query failure
(including an invalid prefix reaching the DB, which should not happen —
callers validate first) is treated the same as "table absent".

# `seed_present?`

```elixir
@spec seed_present?(Ecto.Repo.t(), String.t()) :: boolean()
```

Executes an already-materialized `:seed` `check` SQL string (`SELECT EXISTS (...)`).

# `server_version_major`

```elixir
@spec server_version_major(Ecto.Repo.t()) :: pos_integer() | :unknown
```

The connected server's Postgres major version (`SHOW server_version_num`'s leading digits), for §6.3's preflight.

# `snapshot`

```elixir
@spec snapshot(Ecto.Repo.t(), String.t()) :: snapshot()
```

A full structural snapshot of `prefix` on the target server — one round
trip per class (7 queries total), mirroring
`PhoenixKit.Squash.Generate.Catalog.snapshot/2`'s shape exactly (minus
seeds — see `PhoenixKit.Migrations.Repair.Differ`'s moduledoc for why seed
rows are checked for presence only, never snapshotted for value
comparison). `oban_*` tables/sequences/functions and `schema_migrations`
are excluded — Oban is delegated, never manifested (spec §6.1).

## Why this forces `search_path = ''` for the duration of the snapshot

`pg_get_expr`/`pg_get_indexdef`/`pg_get_constraintdef` (used by the
columns/indexes/constraints queries below to render defaults, predicates,
and definitions) decide **at read time**, from the QUERYING session's
`search_path`, whether to schema-qualify a referenced function/type —
never from how the object was originally created. The manifest's
expected shapes are captured by the generator
(`PhoenixKit.Squash.Generate.Catalog.snapshot/2`, `dev_docs/squash/
generate_baseline.exs`) from a scratch schema its own `validate_schemas!/1`
refuses to let be named `"public"` — specifically so that schema is never
on the generation connection's default search_path — so the generator's
captured text is (by that constraint) always schema-qualified, then
token-substituted for `"__SCHEMA__"` (`DumpHelper.substitute_schema/2`).
A `"public"`-prefix install's OWN default search_path normally DOES
include `public`, so without this, the exact same stored default (e.g.
every `uuid_generate_v7()`-defaulted column — nearly every primary/
foreign key in the chain) would read back UNQUALIFIED here while the
materialized manifest expects it QUALIFIED, manufacturing a false
`:wrong_shape` divergence (`PhoenixKit.Migrations.Repair.Differ`) on the
single most common install topology, on very common objects, for reasons
having nothing to do with real drift. Forcing `search_path = ''` here —
the same idiom `pg_dump` itself relies on for portable, fully-qualified
output — makes this side schema-qualify exactly as consistently as the
generator's capture does, independent of which prefix is being probed.
`pg_catalog` (every query below is a `pg_catalog`/`pg_*` read) stays
resolvable regardless: Postgres always searches it first, whether or not
it is named in `search_path`.

Runs inside `repo.checkout/2` so the `SET`, every catalog query, and the
final `RESET` land on the SAME physical connection — Ecto/DBConnection
checkout nests transparently, so this is equally safe called from inside
`PhoenixKit.Migrations.Repair.Environment.with_lock/2`'s own outer
checkout (the common, direct-connection path) or as the outermost
checkout (the `--unsafe-pooled` path, which skips the lock). The `after`
clause restores `search_path` unconditionally, even if a catalog query
raises — this is session-level state on a connection this process
**borrows** from a pool it shares with the rest of the host application;
leaving it at `''` would silently break any later unqualified query
issued by anything else on that physical connection once it is checked
back in. `RESET search_path` (never a literal previous-value `SET`)
correctly restores the default for every deployment this tool documents
supporting — PhoenixKit's own runtime queries never rely on `search_path`
for a prefixed install (`CLAUDE.md`: "no `search_path` requirement on the
DB role"), so nothing in this codebase's own connections customizes it at
connect time for a `RESET` to lose.

---

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