mix phoenix_kit.consolidate_wrappers (phoenix_kit v2.0.0)

Copy Markdown View Source

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.update zero-pads the version in its filename (..._v05_to_v27...); mix phoenix_kit.gen.migration does 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_tables today; create_phoenix_kit_tables is what phoenix_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-writes ALTER TABLE against 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_migrations already 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.migrate sees 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.migrate finds exactly one PhoenixKit-related file in the collapsed range and runs its up/0, which calls PhoenixKit.Migrations.up/1 directly at the last wrapper's target version (or unpinned — jumping straight to whatever the installed PhoenixKit's current_version/0 is — 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.rollback against the new file) calls PhoenixKit.Migrations.down/1 pinned 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/migrations

Options

  • --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 to priv/repo/migrations