Renumber a migration that collided, and catch the next one

51830b0 · Gabriel Morell · 2026-09-05 20:40

9 files +49 -0
Message
{commit_body(@commit)}

Files changed

renamed apps/room_sanctum/priv/repo/migrations/20260905120000_add_tint_to_foci.exs → apps/room_sanctum/priv/repo/migrations/20260905122000_add_tint_to_foci.exs
+5 −0
@@ -10,6 +10,11 @@ defmodule RoomSanctum.Repo.Migrations.AddTintToFoci do
10 10 a tint of foci gains a new one the moment it is tinted.
11 11 """
12 12
13 + # Renumbered from 20260905120000, which collided with add_plus_to_pythiae.
14 + # Ecto keys schema_migrations on the version alone, so the first of the two
15 + # to run recorded the number and the second was skipped -- while still
16 + # reporting `:up`, because the version it is looked up by was there.
17 + # Silent, and it looked exactly like a migration that had run.
13 18 def change do
14 19 alter table(:cfg_focis) do
15 20 add :tint, :string
added apps/room_sanctum/test/room_sanctum/migrations_test.exs
+44 −0
@@ -0,0 +1,44 @@
1 +defmodule RoomSanctum.MigrationsTest do
2 + use ExUnit.Case, async: true
3 +
4 + @migrations Path.join([__DIR__, "..", "..", "priv", "repo", "migrations"])
5 +
6 + # What Ecto itself considers a migration: a leading version, then a name.
7 + # The directory also holds a .formatter.exs, which is not one.
8 + defp migration_files do
9 + @migrations
10 + |> File.ls!()
11 + |> Enum.filter(&Regex.match?(~r/^\d+_.*\.exs$/, &1))
12 + end
13 +
14 + test "no two migrations share a version" do
15 + # Ecto keys schema_migrations on the version alone. Two files with the
16 + # same number means the first to run records it and the second is skipped
17 + # -- and then *reports itself up*, because the version it is looked up by
18 + # is there. Nothing errors, nothing logs, and the column simply does not
19 + # exist. That cost an afternoon of a Plani crash-looping in production
20 + # against a table it had been told was migrated.
21 + duplicates =
22 + migration_files()
23 + |> Enum.group_by(fn file -> file |> String.split("_") |> hd() end)
24 + |> Enum.filter(fn {_version, files} -> length(files) > 1 end)
25 +
26 + assert duplicates == [],
27 + "these migrations share a version, and all but one will be skipped:\n" <>
28 + Enum.map_join(duplicates, "\n", fn {version, files} ->
29 + " #{version}: #{Enum.join(files, ", ")}"
30 + end)
31 + end
32 +
33 + test "every migration is named after a version that sorts" do
34 + # Ecto runs them in version order, so a name it cannot parse a number out
35 + # of would run somewhere unpredictable.
36 + for file <- migration_files() do
37 + assert {version, "_" <> _rest} = Integer.parse(file)
38 + assert version > 0, "#{file} has no leading version"
39 + end
40 +
41 + # And that the filter is not vacuously true.
42 + assert length(migration_files()) > 10
43 + end
44 +end

Parents: b0906f0