Collapses a consumer app's accumulated PhoenixKit wrapper migrations into ONE migration file.
"Wrapper migrations" are the files mix phoenix_kit.install (the
installer's ..._add_phoenix_kit_tables.exs) and mix phoenix_kit.update /
mix phoenix_kit.gen.migration (..._phoenix_kit_(force_)?update_vNN_to_vMM.exs)
write into the host app's priv/repo/migrations/ — each one nothing but a
version-pinned call to PhoenixKit.Migrations.up/1 / .down/1. A long-lived
app accumulates dozens of these; this task is the documented remedy from
dev_docs/plans/2026-07-14-squash-migrations-spec.md §5.3/§9.
Why this exists
Once PhoenixKit's own history is squashed past a floor version (a later
release), a consumer-authored migration that was interleaved between two
wrapper files and depends on a below-floor intermediate shape of a
PhoenixKit-owned object breaks on a fresh install — it now replays against
the collapsed baseline shape instead of the exact intermediate shape that
existed at that point in the original chain (spec §5.3's documented
breakage class; the flagship consumer's real
..._phoenix_kit_catalogue_v013_base_price.exs survives only because it
happens to carry its own IF EXISTS guard). Collapsing the wrapper chain
into one file removes the "in-between" position such a migration could ever
have been interleaved at, making the whole breakage class moot for any repo
that runs this task.
What counts as a wrapper (content, not filename)
A migration file counts if — and only if — it textually contains BOTH a
PhoenixKit.Migrations.up(...) call and a PhoenixKit.Migrations.down(...)
call. This is deliberately a content signature, not a filename pattern:
mix phoenix_kit.updatezero-pads the version in its filename (..._v05_to_v27...);mix phoenix_kit.gen.migrationdoes not (..._v5_to_v27...) — both are recognized, because neither filename shape is inspected at all.- The installer's own filename convention has drifted historically
(
add_phoenix_kit_tablestoday;create_phoenix_kit_tablesis whatphoenix_kit.gen.migration's pre-existing version-scan bug still assumes) — both forms, and any other name, are recognized the same way. - A consumer-authored migration that merely mentions "phoenix_kit" in
its name or comments — the real
..._phoenix_kit_catalogue_v013_base_price.exs, which hand-writesALTER TABLEagainst a PhoenixKit-owned column — does not call the wrapper API, so it is correctly never a collapse candidate and always counts as foreign for the interleaving check below. That is the whole point: this is exactly the file class a floor-raising squash can later break, and exactly the file class this task must never touch or silently reorder past without the operator's explicit say-so.
Classification is pure text/regex scanning — the scanner never evaluates or
parses the files it reads as code, so a syntactically broken foreign
migration elsewhere in the directory cannot crash it. A file that contains
one of the two calls but not its counterpart, or whose up/down calls
disagree with each other on prefix:, is reported as unparseable and
always blocks the run (never bypassable with --force, unlike the
interleaving refusal below) — this task never guesses at a wrapper's
intended shape.
The interleaving refusal
If any non-wrapper (foreign) file's timestamp falls between the earliest
and latest in-scope wrapper file's timestamp, this task refuses by default:
collapsing the wrapper chain into one file at the earliest wrapper's
timestamp changes that foreign migration's execution order relative to
what is now a single PhoenixKit step, and only the operator can judge
whether that reordering is safe for that specific migration. Pass --force
to proceed anyway. The foreign file(s) are left completely untouched on
disk either way — only their relative position against the (now
collapsed) PhoenixKit chain changes. Shape-guard any consumer migration
that touches PhoenixKit-owned objects (IF EXISTS / column-existence
checks) before doing this, the same way you would before a future
PhoenixKit floor-raising squash.
Wrapper files that belong to a different schema prefix (multi-prefix
installs) are never treated as foreign for this purpose — they are simply
out of scope for the current run. Pass --prefix to pick which chain to
collapse when more than one is found (the run refuses, listing every
prefix found, until you do).
The crux invariant: Ecto version bookkeeping
Ecto's schema_migrations table records applied migrations by the
integer version parsed from each file's leading digits — it has no notion
of a file's contents, only what timestamp it is filed under. This task's
new file reuses the first replaced wrapper's exact timestamp, and that
one decision is what makes collapsing safe on already-migrated databases:
- Already-migrated database (any environment where the original
wrapper chain already ran to completion):
schema_migrationsalready has a row for the first wrapper's version number. After this task deletes the old files and writes the new one under that same version number,mix ecto.migratesees that version as already applied and skips the new file too — it is never re-executed. The other replaced wrappers' version rows become harmless orphans: permanent journal entries pointing at filenames that no longer exist, which Ecto has never required to still be present in order to consider a version "applied". - Fresh database (never migrated through any of the replaced files):
mix ecto.migratefinds exactly one PhoenixKit-related file in the collapsed range and runs itsup/0, which callsPhoenixKit.Migrations.up/1directly at the last wrapper's target version (or unpinned — jumping straight to whatever the installed PhoenixKit'scurrent_version/0is — if the collapsed chain itself ended unpinned, i.e. consisted only of the installer). PhoenixKit's own fresh-install path (initial_version == 0) always installs directly to the target version in one pass regardless of how many intermediate pins the original chain had, so collapsing loses no fidelity for a fresh install. - Rollback (
mix ecto.rollbackagainst the new file) callsPhoenixKit.Migrations.down/1pinned to the first replaced wrapper's own starting version — exactly what rolling the entire original chain back would have produced. This is an intentional, permanent loss of granularity: after this task runs, you can no longer roll back to a version that used to sit strictly between the first and last replaced wrapper. Losing exactly that granularity is what "collapsing history" means — do not run this task against a chain you still expect to partially roll back within.
Precondition this task cannot check for you (it never opens a database
connection, by design — see "DB-free by design" below): every environment
must be either fully migrated through the last replaced wrapper, or not
migrated through any of them, before you --apply and ship the result. A
database that is only partially through the old chain — has applied
wrapper #1 but not wrapper #2 — starts, after collapsing, from a file whose
version is already marked applied (wrapper #1's own version number), so
mix ecto.migrate will skip the new file too and that database will never
advance to the collapsed target version. Confirm every environment's
applied-migration state (your app's mix ecto.migrate status, or
mix phoenix_kit.update --status for the PhoenixKit side specifically)
before applying — the same discipline mix phoenix_kit.update's own
upgrade guide asks for when bumping a version pin.
DB-free by design
This task only ever reads and writes files under the migrations directory.
It never opens a database connection and never inspects schema_migrations
— the precondition above is the operator's to confirm, on every
environment, before --apply.
Usage
mix phoenix_kit.consolidate_wrappers # dry run (default)
mix phoenix_kit.consolidate_wrappers --apply # write the change
mix phoenix_kit.consolidate_wrappers --apply --force # bypass the interleaving refusal
mix phoenix_kit.consolidate_wrappers --prefix auth # pick one chain among several
mix phoenix_kit.consolidate_wrappers --migrations-dir path/to/migrationsOptions
--dry-run— print the plan without touching anything (the default; accepted explicitly for scripting clarity — identical to passing nothing)--apply— write the new file and delete the ones it replaces--force— proceed even though a foreign migration is interleaved between the first and last in-scope PhoenixKit wrapper (see "The interleaving refusal"); never bypasses an unparseable-wrapper refusal--prefix SCHEMA— only consider wrapper files for this schema prefix; required when wrappers for more than one prefix are found--migrations-dir DIR— defaults topriv/repo/migrations