Packages
phoenix_kit
1.7.197
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
221 Versions
Jump to
Current section
221 Versions
Compare versions
9
files changed
+625
additions
-36
deletions
unknownCHANGELOG.md
File is too large to be displayed (100 KB limit).
| @@ -1,6 +1,6 @@ | |
| 1 1 | {<<"links">>,[{<<"GitHub">>,<<"https://github.com/BeamLabEU/phoenix_kit">>}]}. |
| 2 2 | {<<"name">>,<<"phoenix_kit">>}. |
| 3 | - {<<"version">>,<<"1.7.196">>}. |
| 3 | + {<<"version">>,<<"1.7.197">>}. |
| 4 4 | {<<"description">>, |
| 5 5 | <<65,32,102,111,117,110,100,97,116,105,111,110,32,102,111,114,32,98,117,105, |
| 6 6 | 108,100,105,110,103,32,69,108,105,120,105,114,32,80,104,111,101,110,105, |
| @@ -438,7 +438,7 @@ | |
| 438 438 | <<"lib/phoenix_kit/scheduled_jobs.ex">>, |
| 439 439 | <<"lib/phoenix_kit/application.ex">>,<<"lib/phoenix_kit/workers">>, |
| 440 440 | <<"lib/phoenix_kit/workers/oauth_config_loader.ex">>, |
| 441 | - <<"lib/phoenix_kit/install">>, |
| 441 | + <<"lib/phoenix_kit/install">>,<<"lib/phoenix_kit/install/child_order.ex">>, |
| 442 442 | <<"lib/phoenix_kit/install/igniter_compat.ex">>, |
| 443 443 | <<"lib/phoenix_kit/install/repo_detection.ex">>, |
| 444 444 | <<"lib/phoenix_kit/install/oauth_config.ex">>, |
| @@ -586,6 +586,7 @@ | |
| 586 586 | <<"lib/phoenix_kit/migrations/postgres/v119.ex">>, |
| 587 587 | <<"lib/phoenix_kit/migrations/postgres/v06.ex">>, |
| 588 588 | <<"lib/phoenix_kit/migrations/postgres/v59.ex">>, |
| 589 | + <<"lib/phoenix_kit/migrations/postgres/v151.ex">>, |
| 589 590 | <<"lib/phoenix_kit/migrations/postgres/v11.ex">>, |
| 590 591 | <<"lib/phoenix_kit/migrations/postgres/v61.ex">>, |
| 591 592 | <<"lib/phoenix_kit/migrations/postgres/v96.ex">>, |
| @@ -14,7 +14,10 @@ defmodule Mix.Tasks.PhoenixKit.Doctor do | |
| 14 14 | |
| 15 15 | ## Options |
| 16 16 | |
| 17 | - * `--prefix` - Database schema prefix (default: "public") |
| 17 | + * `--prefix` - Database schema prefix. When omitted, resolves from |
| 18 | + `config :phoenix_kit, :prefix`, then `"public"` — the same resolution |
| 19 | + `mix phoenix_kit.update` / `--status` use, so a prefixed install is |
| 20 | + diagnosed against the schema it actually lives in. |
| 18 21 | |
| 19 22 | ## Checks Performed |
| 20 23 | |
| @@ -23,20 +26,24 @@ defmodule Mix.Tasks.PhoenixKit.Doctor do | |
| 23 26 | 3. **Pool Configuration** — Pool size, checkout timeout, queue settings |
| 24 27 | 4. **PgBouncer Detection** — Is PgBouncer between app and PostgreSQL? |
| 25 28 | 5. **Migration State** — PhoenixKit version (COMMENT), schema_migrations alignment |
| 26 | - 6. **Pending Migrations** — Migration files not yet recorded in schema_migrations |
| 27 | - 7. **UUID Column Types** — Detects varchar uuid columns that crash Ecto on startup |
| 28 | - 8. **NULL UUIDs in FK Sources** — Detects NULL uuids that cause infinite backfill loops |
| 29 | - 9. **Orphaned FK References** — Detects orphaned rows that block FK constraint creation |
| 30 | - 10. **Lock Conflicts** — Any blocked or long-running queries? |
| 31 | - 11. **Orphaned Connections** — Idle-in-transaction or stuck connections |
| 32 | - 12. **Oban Configuration** — Queues and plugins that consume pool connections |
| 33 | - 13. **Supervisor Children** — What's running (update_mode vs full)? |
| 34 | - 14. **Update Mode** — Is update_mode active? |
| 35 | - 15. **daisyUI Version** — Is the host's vendored daisyUI recent enough? |
| 29 | + 6. **Schema Drift** — Columns a migration should have added but the DB lacks |
| 30 | + 7. **Pending Migrations** — Migration files not yet recorded in schema_migrations |
| 31 | + 8. **UUID Column Types** — Detects varchar uuid columns that crash Ecto on startup |
| 32 | + 9. **NULL UUIDs in FK Sources** — Detects NULL uuids that cause infinite backfill loops |
| 33 | + 10. **Orphaned FK References** — Detects orphaned rows that block FK constraint creation |
| 34 | + 11. **Lock Conflicts** — Any blocked or long-running queries? |
| 35 | + 12. **Orphaned Connections** — Idle-in-transaction or stuck connections |
| 36 | + 13. **Oban Configuration** — Queues and plugins that consume pool connections |
| 37 | + 14. **Supervisor Children** — What's running (update_mode vs full)? |
| 38 | + 15. **Child Start Order** — Does the Repo start before PhoenixKit/Oban in application.ex? |
| 39 | + 16. **Update Mode** — Is update_mode active? |
| 40 | + 17. **daisyUI Version** — Is the host's vendored daisyUI recent enough? |
| 36 41 | """ |
| 37 42 | |
| 38 43 | use Mix.Task |
| 39 44 | |
| 45 | + alias PhoenixKit.Install.ChildOrder |
| 46 | + alias PhoenixKit.Install.PrefixConfig |
| 40 47 | alias PhoenixKit.Migrations.Postgres |
| 41 48 | |
| 42 49 | @shortdoc "Diagnoses PhoenixKit installation, migration, and runtime issues" |
| @@ -48,10 +55,21 @@ defmodule Mix.Tasks.PhoenixKit.Doctor do | |
| 48 55 | def run(argv) do |
| 49 56 | {opts, _argv, _errors} = OptionParser.parse(argv, switches: @switches, aliases: @aliases) |
| 50 57 | |
| 51 | - prefix = opts[:prefix] || "public" |
| 52 | - |
| 53 58 | # Start app with minimal footprint (same approach as phoenix_kit.update) |
| 54 59 | Mix.Task.run("app.config") |
| 60 | + |
| 61 | + # Resolve the prefix AFTER app.config loads config, so a configured |
| 62 | + # non-public prefix is honored — same resolution the updater/status use |
| 63 | + # (--prefix flag → config :phoenix_kit, :prefix → "public"). Reading |
| 64 | + # opts[:prefix] || "public" here queries the version marker at public and |
| 65 | + # reports a prefixed install as "not installed". |
| 66 | + prefix = PrefixConfig.resolve_prefix(opts) |
| 67 | + |
| 68 | + # Snapshot the host's Oban config BEFORE cap_repo_pool_size/1 zeroes its |
| 69 | + # queues/plugins (it does that to conserve connections in update_mode) — |
| 70 | + # otherwise the Oban Configuration check reports "0 queues, 0 plugins". |
| 71 | + oban_config = Application.get_env(Mix.Project.config()[:app], Oban) |
| 72 | + |
| 55 73 | cap_repo_pool_size(2) |
| 56 74 | Application.put_env(:phoenix_kit, :update_mode, true) |
| 57 75 | Mix.Task.run("app.start") |
| @@ -64,14 +82,16 @@ defmodule Mix.Tasks.PhoenixKit.Doctor do | |
| 64 82 | run_check("Pool Configuration", fn -> check_pool_config() end), |
| 65 83 | run_check("PgBouncer Detection", fn -> check_pgbouncer() end), |
| 66 84 | run_check("Migration State", fn -> check_migration_state(prefix) end), |
| 85 | + run_check("Schema Drift", fn -> check_schema_drift(prefix) end), |
| 67 86 | run_check("Pending Migrations", fn -> check_pending_migrations() end), |
| 68 87 | run_check("UUID Column Types", fn -> check_uuid_column_types(prefix) end), |
| 69 88 | run_check("NULL UUIDs in FK Sources", fn -> check_null_uuids(prefix) end), |
| 70 89 | run_check("Orphaned FK References", fn -> check_orphaned_fk_refs(prefix) end), |
| 71 90 | run_check("Lock Conflicts", fn -> check_lock_conflicts() end), |
| 72 91 | run_check("Orphaned Connections", fn -> check_orphaned_connections() end), |
| 73 | - run_check("Oban Configuration", fn -> check_oban_config() end), |
| 92 | + run_check("Oban Configuration", fn -> check_oban_config(oban_config) end), |
| 74 93 | run_check("PhoenixKit Supervisor", fn -> check_supervisor_state() end), |
| 94 | + run_check("Child Start Order", fn -> check_child_order() end), |
| 75 95 | run_check("Update Mode", fn -> check_update_mode() end), |
| 76 96 | run_check("daisyUI Version", fn -> check_daisyui() end) |
| 77 97 | ] |
| @@ -226,6 +246,71 @@ defmodule Mix.Tasks.PhoenixKit.Doctor do | |
| 226 246 | end |
| 227 247 | end |
| 228 248 | |
| 249 | + # Columns a given migration version adds. If the version marker claims that |
| 250 | + # version (or higher) but the column is missing at the prefix, the install |
| 251 | + # drifted — the marker is ahead of the actual schema (e.g. a version renumber |
| 252 | + # that crossed an upgrade, or an earlier prefix-confused migration run). A |
| 253 | + # query that selects the column then crashes at runtime, and re-running the |
| 254 | + # migrator is a no-op because the marker already covers that version. |
| 255 | + @expected_columns [ |
| 256 | + {150, "phoenix_kit_users_tokens", "browser"}, |
| 257 | + {150, "phoenix_kit_users_tokens", "os"} |
| 258 | + ] |
| 259 | + |
| 260 | + defp check_schema_drift(prefix) do |
| 261 | + repo = get_repo!() |
| 262 | + escaped_prefix = String.replace(prefix, "'", "\\'") |
| 263 | + marker = get_comment_version(repo, escaped_prefix) |
| 264 | + |
| 265 | + if marker == 0 do |
| 266 | + {:pass, "PhoenixKit not installed at prefix #{inspect(prefix)} — nothing to check."} |
| 267 | + else |
| 268 | + missing = |
| 269 | + @expected_columns |
| 270 | + |> Enum.filter(fn {min_version, _t, _c} -> marker >= min_version end) |
| 271 | + |> Enum.reject(fn {_v, table, column} -> |
| 272 | + column_exists?(repo, escaped_prefix, table, column) |
| 273 | + end) |
| 274 | + |
| 275 | + report_schema_drift(missing, marker, prefix) |
| 276 | + end |
| 277 | + end |
| 278 | + |
| 279 | + defp report_schema_drift([], marker, _prefix), |
| 280 | + do: {:pass, "Columns expected at V#{marker} are present."} |
| 281 | + |
| 282 | + defp report_schema_drift(missing, marker, prefix) do |
| 283 | + names = Enum.map_join(missing, ", ", fn {v, t, c} -> "#{t}.#{c} (V#{v})" end) |
| 284 | + lowest = missing |> Enum.map(fn {v, _t, _c} -> v end) |> Enum.min() |
| 285 | + p = if prefix == "public", do: "public.", else: "#{prefix}." |
| 286 | + |
| 287 | + {:fail, |
| 288 | + "Marker says V#{marker} but these columns are missing: #{names}. The install drifted " <> |
| 289 | + "(marker ahead of schema). Roll the marker back one version and re-run the migrator — " <> |
| 290 | + "the column adds are idempotent (add_if_not_exists), so this is safe:\n" <> |
| 291 | + " COMMENT ON TABLE #{p}phoenix_kit IS '#{lowest - 1}';\n" <> |
| 292 | + " mix phoenix_kit.update#{prefix_flag(prefix)}"} |
| 293 | + end |
| 294 | + |
| 295 | + defp column_exists?(repo, escaped_prefix, table, column) do |
| 296 | + query = """ |
| 297 | + SELECT EXISTS ( |
| 298 | + SELECT FROM information_schema.columns |
| 299 | + WHERE table_schema = '#{escaped_prefix}' |
| 300 | + AND table_name = '#{table}' |
| 301 | + AND column_name = '#{column}' |
| 302 | + ) |
| 303 | + """ |
| 304 | + |
| 305 | + case repo.query(query, [], log: false) do |
| 306 | + {:ok, %{rows: [[true]]}} -> true |
| 307 | + _ -> false |
| 308 | + end |
| 309 | + end |
| 310 | + |
| 311 | + defp prefix_flag("public"), do: "" |
| 312 | + defp prefix_flag(prefix), do: " --prefix=#{prefix}" |
| 313 | + |
| 229 314 | defp check_pending_migrations do |
| 230 315 | repo = get_repo!() |
| 231 316 | migrations_path = Path.join(["priv", "repo", "migrations"]) |
| @@ -577,22 +662,20 @@ defmodule Mix.Tasks.PhoenixKit.Doctor do | |
| 577 662 | end |
| 578 663 | end |
| 579 664 | |
| 580 | - defp check_oban_config do |
| 581 | - app = Mix.Project.config()[:app] |
| 665 | + # Reports the Oban config snapshotted in run/1 BEFORE cap_repo_pool_size/1 |
| 666 | + # zeroed its queues/plugins — reading it live here would always show 0/0. |
| 667 | + defp check_oban_config(nil), do: {:pass, "Oban not configured"} |
| 582 668 | |
| 583 | - case Application.get_env(app, Oban) do |
| 584 | - nil -> |
| 585 | - {:pass, "Oban not configured"} |
| 669 | + defp check_oban_config(config) when is_list(config) do |
| 670 | + queues = Keyword.get(config, :queues, []) |
| 671 | + plugins = Keyword.get(config, :plugins, []) |
| 586 672 | |
| 587 | - config -> |
| 588 | - queues = Keyword.get(config, :queues, []) |
| 589 | - plugins = Keyword.get(config, :plugins, []) |
| 590 | - |
| 591 | - {:pass, |
| 592 | - "#{length(queues)} queues, #{length(plugins)} plugins. Each active queue uses 1 pool connection."} |
| 593 | - end |
| 673 | + {:pass, |
| 674 | + "#{length(queues)} queues, #{length(plugins)} plugins. Each active queue uses 1 pool connection."} |
| 594 675 | end |
| 595 676 | |
| 677 | + defp check_oban_config(_other), do: {:pass, "Oban configured (non-keyword config)"} |
| 678 | + |
| 596 679 | defp check_supervisor_state do |
| 597 680 | case Process.whereis(PhoenixKit.Supervisor) do |
| 598 681 | nil -> |
| @@ -605,6 +688,45 @@ defmodule Mix.Tasks.PhoenixKit.Doctor do | |
| 605 688 | end |
| 606 689 | end |
| 607 690 | |
| 691 | + # Reads the host application.ex and verifies the Repo starts BEFORE |
| 692 | + # PhoenixKit.Supervisor and Oban — a child listed before the Repo crashes the |
| 693 | + # app at boot (PhoenixKit reads Settings from the DB; Oban needs the pool). |
| 694 | + # The runtime supervisor check above can't catch this (by the time doctor |
| 695 | + # runs, everything has already started), so we read the source order. |
| 696 | + defp check_child_order do |
| 697 | + repo = get_repo!() |
| 698 | + |
| 699 | + case host_application_source() do |
| 700 | + {:ok, path, source} -> |
| 701 | + where = Path.relative_to_cwd(path) |
| 702 | + |
| 703 | + case ChildOrder.check(source, repo) do |
| 704 | + {:ok, detail} -> |
| 705 | + {:pass, "#{detail} (#{where})"} |
| 706 | + |
| 707 | + {:misordered, mods} -> |
| 708 | + names = Enum.map_join(mods, ", ", &inspect/1) |
| 709 | + |
| 710 | + {:fail, |
| 711 | + "#{names} start BEFORE #{inspect(repo)} in #{where}. PhoenixKit.Supervisor " <> |
| 712 | + "reads Settings from the database and Oban needs the connection pool, so both " <> |
| 713 | + "must be listed AFTER your Repo. Move #{inspect(repo)} above them in the " <> |
| 714 | + "children list to fix the boot crash."} |
| 715 | + |
| 716 | + :no_repo_in_children -> |
| 717 | + {:warn, |
| 718 | + "Couldn't find #{inspect(repo)} in the children list of #{where} — verify " <> |
| 719 | + "PhoenixKit.Supervisor and Oban are started after your Repo."} |
| 720 | + |
| 721 | + :no_children -> |
| 722 | + {:warn, "Couldn't locate a children list in #{where} to verify start order."} |
| 723 | + end |
| 724 | + |
| 725 | + :error -> |
| 726 | + {:warn, "Couldn't locate your application.ex to verify child start order."} |
| 727 | + end |
| 728 | + end |
| 729 | + |
| 608 730 | defp check_update_mode do |
| 609 731 | update_mode = Application.get_env(:phoenix_kit, :update_mode, false) |
| 610 732 | |
| @@ -626,6 +748,40 @@ defmodule Mix.Tasks.PhoenixKit.Doctor do | |
| 626 748 | end |
| 627 749 | end |
| 628 750 | |
| 751 | + # Locate the host's application.ex — first via the compiled application |
| 752 | + # module's source path, then the conventional lib/<app>/application.ex. |
| 753 | + defp host_application_source do |
| 754 | + app = Mix.Project.config()[:app] |
| 755 | + |
| 756 | + candidates = |
| 757 | + [ |
| 758 | + case Application.spec(app, :mod) do |
| 759 | + {mod, _args} -> module_source(mod) |
| 760 | + _ -> nil |
| 761 | + end, |
| 762 | + Path.join(["lib", "#{app}", "application.ex"]) |
| 763 | + ] |
| 764 | + |> Enum.reject(&is_nil/1) |
| 765 | + |
| 766 | + Enum.find_value(candidates, :error, fn path -> |
| 767 | + case File.read(path) do |
| 768 | + {:ok, source} -> {:ok, path, source} |
| 769 | + _ -> nil |
| 770 | + end |
| 771 | + end) |
| 772 | + end |
| 773 | + |
| 774 | + defp module_source(mod) do |
| 775 | + with {:module, _} <- Code.ensure_loaded(mod), |
| 776 | + source when not is_nil(source) <- mod.module_info(:compile)[:source] do |
| 777 | + to_string(source) |
| 778 | + else |
| 779 | + _ -> nil |
| 780 | + end |
| 781 | + rescue |
| 782 | + _ -> nil |
| 783 | + end |
| 784 | + |
| 629 785 | defp cap_repo_pool_size(pool_size) do |
| 630 786 | app = Mix.Project.config()[:app] |
| 631 787 | repos = Application.get_env(app, :ecto_repos, []) |
| @@ -0,0 +1,153 @@ | |
| 1 | + defmodule PhoenixKit.Install.ChildOrder do |
| 2 | + @moduledoc """ |
| 3 | + Reads a host application's `application.ex` and checks that the Ecto Repo |
| 4 | + starts BEFORE `PhoenixKit.Supervisor` and `Oban` in the supervision |
| 5 | + `children` list. |
| 6 | + |
| 7 | + `PhoenixKit.Supervisor` reads Settings / OAuth configuration from the |
| 8 | + database as it boots, and Oban opens a connection pool against the same |
| 9 | + Repo — so both MUST appear after the Repo in the children list. A |
| 10 | + mis-ordered list crashes the app at startup (typically an Oban crash-loop: |
| 11 | + the pool has no database to reach yet). |
| 12 | + |
| 13 | + `mix phoenix_kit.install` positions its child after the detected Repo, but a |
| 14 | + hand-edited `application.ex` — or an Igniter anchor miss that prepends the |
| 15 | + child instead of inserting it after the Repo — can regress the order. This |
| 16 | + module is the deterministic safety net: `mix phoenix_kit.doctor` runs |
| 17 | + `check/2` against the host's source so both fresh and existing installs are |
| 18 | + caught, independent of how the children ended up ordered. |
| 19 | + |
| 20 | + Everything here is a pure function over source text — no application boot, |
| 21 | + no database — so it is unit-testable in isolation. |
| 22 | + """ |
| 23 | + |
| 24 | + # Children that depend on the Repo already being started. |
| 25 | + @repo_dependents [PhoenixKit.Supervisor, Oban] |
| 26 | + |
| 27 | + @typedoc "A module extracted from a child spec, or `nil` when unrecognized." |
| 28 | + @type child :: module() | nil |
| 29 | + |
| 30 | + @doc """ |
| 31 | + Checks the child ordering in `source` relative to `repo_module`. |
| 32 | + |
| 33 | + Returns: |
| 34 | + |
| 35 | + * `{:ok, detail}` — the Repo precedes every Repo-dependent child that is |
| 36 | + present (or none are present); `detail` is a human-readable summary. |
| 37 | + * `{:misordered, [module]}` — the listed Repo-dependent modules appear |
| 38 | + *before* the Repo. This is the crash case. |
| 39 | + * `:no_repo_in_children` — `repo_module` wasn't found in the children list, |
| 40 | + so ordering can't be judged (verify manually). |
| 41 | + * `:no_children` — no `children` list could be located in the source. |
| 42 | + """ |
| 43 | + @spec check(String.t(), module()) :: |
| 44 | + {:ok, String.t()} | {:misordered, [module()]} | :no_repo_in_children | :no_children |
| 45 | + def check(source, repo_module) when is_binary(source) and is_atom(repo_module) do |
| 46 | + case ordered_children(source) do |
| 47 | + {:ok, mods} -> |
| 48 | + case Enum.find_index(mods, &(&1 == repo_module)) do |
| 49 | + nil -> |
| 50 | + :no_repo_in_children |
| 51 | + |
| 52 | + repo_index -> |
| 53 | + offenders = Enum.filter(@repo_dependents, &before?(mods, &1, repo_index)) |
| 54 | + |
| 55 | + if offenders == [] do |
| 56 | + {:ok, describe(mods, repo_module, repo_index)} |
| 57 | + else |
| 58 | + {:misordered, offenders} |
| 59 | + end |
| 60 | + end |
| 61 | + |
| 62 | + :error -> |
| 63 | + :no_children |
| 64 | + end |
| 65 | + end |
| 66 | + |
| 67 | + @doc """ |
| 68 | + Extracts the ordered list of head modules from the host's `children` list. |
| 69 | + |
| 70 | + Each element is the module heading a child spec (`MyApp.Repo`, |
| 71 | + `{Oban, opts}` → `Oban`, `{Phoenix.PubSub, ...}` → `Phoenix.PubSub`), or |
| 72 | + `nil` for a spec whose module can't be read cheaply (e.g. a `%{}` map spec). |
| 73 | + Returns `:error` if no children list can be found. |
| 74 | + """ |
| 75 | + @spec ordered_children(String.t()) :: {:ok, [child()]} | :error |
| 76 | + def ordered_children(source) when is_binary(source) do |
| 77 | + with {:ok, ast} <- Code.string_to_quoted(source), |
| 78 | + [list | _] <- children_lists(ast) do |
| 79 | + {:ok, Enum.map(list, &child_head_module/1)} |
| 80 | + else |
| 81 | + _ -> :error |
| 82 | + end |
| 83 | + end |
| 84 | + |
| 85 | + # ── Locating the children list ────────────────────────────────────── |
| 86 | + |
| 87 | + # Collects every candidate children list in document order, from the two |
| 88 | + # shapes the Phoenix generator and hand-written apps use: |
| 89 | + # children = [ ... ] |
| 90 | + # Supervisor.start_link([ ... ], opts) |
| 91 | + defp children_lists(ast) do |
| 92 | + {_ast, acc} = Macro.prewalk(ast, [], fn node, acc -> {node, collect_list(node, acc)} end) |
| 93 | + acc |> Enum.reverse() |> Enum.map(fn {_pos, list} -> list end) |
| 94 | + end |
| 95 | + |
| 96 | + # `children = [ ... ]` |
| 97 | + defp collect_list({:=, meta, [{:children, _, ctx}, list]}, acc) |
| 98 | + when is_atom(ctx) and is_list(list), |
| 99 | + do: [{meta[:line] || 0, list} | acc] |
| 100 | + |
| 101 | + # `Supervisor.start_link([ ... ], opts)` |
| 102 | + defp collect_list( |
| 103 | + {{:., _, [{:__aliases__, _, [:Supervisor]}, :start_link]}, meta, [list | _]}, |
| 104 | + acc |
| 105 | + ) |
| 106 | + when is_list(list), |
| 107 | + do: [{meta[:line] || 0, list} | acc] |
| 108 | + |
| 109 | + defp collect_list(_node, acc), do: acc |
| 110 | + |
| 111 | + # ── Reading a child spec's head module ────────────────────────────── |
| 112 | + |
| 113 | + # Bare module: `MyApp.Repo` |
| 114 | + defp child_head_module({:__aliases__, _, parts}), do: safe_concat(parts) |
| 115 | + |
| 116 | + # 3+-element tuple literal: `{Mod, a, b}` — module heads it. |
| 117 | + defp child_head_module({:{}, _, [first | _]}), do: child_head_module(first) |
| 118 | + |
| 119 | + # 2-element tuple literal: `{Oban, opts}` / `{Phoenix.PubSub, name: ...}`. |
| 120 | + # (Alias/call/`{:{}}` nodes are all 3-tuples, so only genuine 2-tuples reach |
| 121 | + # here.) |
| 122 | + defp child_head_module({first, _second}), do: child_head_module(first) |
| 123 | + |
| 124 | + defp child_head_module(_other), do: nil |
| 125 | + |
| 126 | + defp safe_concat(parts) do |
| 127 | + if Enum.all?(parts, &is_atom/1), do: Module.concat(parts), else: nil |
| 128 | + end |
| 129 | + |
| 130 | + # ── Helpers ───────────────────────────────────────────────────────── |
| 131 | + |
| 132 | + defp before?(mods, module, repo_index) do |
| 133 | + case Enum.find_index(mods, &(&1 == module)) do |
| 134 | + nil -> false |
| 135 | + index -> index < repo_index |
| 136 | + end |
| 137 | + end |
| 138 | + |
| 139 | + defp describe(mods, repo_module, repo_index) do |
| 140 | + present = |
| 141 | + @repo_dependents |
| 142 | + |> Enum.filter(&(&1 in mods)) |
| 143 | + |> Enum.map(&inspect/1) |
| 144 | + |
| 145 | + dependents = |
| 146 | + case present do |
| 147 | + [] -> "no Repo-dependent children in the list" |
| 148 | + names -> "before #{Enum.join(names, ", ")}" |
| 149 | + end |
| 150 | + |
| 151 | + "#{inspect(repo_module)} (position #{repo_index}) starts #{dependents}" |
| 152 | + end |
| 153 | + end |
| @@ -1309,7 +1309,7 @@ defmodule PhoenixKit.Migrations.Postgres do | |
| 1309 1309 | alias PhoenixKit.Migrations.Postgres.Helpers |
| 1310 1310 | |
| 1311 1311 | @initial_version 1 |
| 1312 | - @current_version 150 |
| 1312 | + @current_version 151 |
| 1313 1313 | @default_prefix "public" |
| 1314 1314 | |
| 1315 1315 | # First version whose SQL references uuid_generate_v7(). Chains that |
Loading more files…