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.
Summary
Types
:absent (table missing), nil (table exists, no comment), or the numeric comment.
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).
Functions
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).
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.
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".
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".
Executes an already-materialized :seed check SQL string (SELECT EXISTS (...)).
The connected server's Postgres major version (SHOW server_version_num's leading digits), for §6.3's preflight.
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).
Types
@type raw_comment() :: :absent | nil | :unparseable | non_neg_integer()
:absent (table missing), nil (table exists, no comment), or the numeric comment.
@type snapshot() :: %{ tables: %{required(String.t()) => map()}, columns: %{required({String.t(), String.t()}) => map()}, indexes: %{required(String.t()) => map()}, constraints: %{required({String.t(), String.t()}) => map()}, sequences: %{required(String.t()) => map()}, functions: %{required({String.t(), String.t()}) => map()}, extensions: %{required(String.t()) => 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).
Functions
@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
@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.
@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.
@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".
@spec seed_present?(Ecto.Repo.t(), String.t()) :: boolean()
Executes an already-materialized :seed check SQL string (SELECT EXISTS (...)).
@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.
@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 searchpath — 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 insearch_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.