V163: UUID primary-key integrity.
Repairs any phoenix_kit_* table whose uuid column is the wrong type,
nullable, or not the primary key — the state V40/V56/V74 are each supposed to
make impossible, and which a production install reached anyway.
The reported state
On a database upgraded through the whole chain since V01,
phoenix_kit_email_events had uuid as character varying(255), nullable,
no default, and the table had no primary key at all. 149 other tables were
correct, so this was one table falling out of the conversion rather than a
broken upgrade.
Why the chain missed it, twice
V40's guard tests EXISTENCE, not TYPE. An older release created that column as Ecto
:string, sounless column_exists?(table, :uuid, …)was already true and V40 skipped the table wholesale — not just theADD COLUMN, but the backfill, theSET NOT NULLand the unique index with it. The table is in V40's@tables_to_migrate; being listed did not help.V56's type conversion shipped after some hosts had already passed V56.
ensure_all_uuid_columns_native_type/2— which converts exactly this varchar column — was added to V56 on 2026-03-02, seventeen days after V56 itself (2026-02-13). A recorded version never re-runs, so every host that crossed V56 in that window kept the broken column permanently. V56'sNOT NULLand index repairs also run off hardcoded table lists thatphoenix_kit_email_eventsappears in none of.
V74 then dropped the legacy bigint id but could not promote uuid to
primary key — wrong type, nullable — and did not verify its own documented
post-condition ("after V74, every PhoenixKit table has uuid as its PK").
Nothing raised.
Why this migration is catalog-driven
Every previous attempt enumerated tables by hand, and this table was missing from every list. This one asks the catalog which tables are actually broken, so a table nobody remembered to list is repaired anyway — and so a table that is already correct is skipped without needing to be named.
Large tables are deferred, not silently rewritten
ALTER COLUMN … TYPE uuid rewrites the table under an ACCESS EXCLUSIVE
lock, SET NOT NULL scans it under the same lock, and ADD PRIMARY KEY
builds a unique index under it too. All three are O(rows), so the size limit
gates the whole repair rather than only the rewrite — a keyless table needs no
type change and would otherwise have had an index built over every row. On a
big events table behind PgBouncer that is connection-pool exhaustion during
mix ecto.migrate, not a pause, so above two million rows the table is left
exactly as it was and logged with the command to run in a maintenance window.
This migration never raises on the happy path. A library does not own its
hosts' deploy runbooks, and turning a latent problem (one audit table without
a PK, on a version where no schema maps to it) into a failed deploy across a
fleet is a worse outcome than the problem. mix phoenix_kit.doctor is the
loud channel and reports precisely what was deferred.