Moves every module's legacy media folders to where the host's
attachments_parent_folder / attachments_folder_name hooks now put new
ones.
The engine itself knows nothing about any module's schemas — it collects
plans (plain maps, see PhoenixKit.Modules.Storage.Reorganizer.Action)
from every enabled module's PhoenixKit.Modules.Storage.Reorganizer.Source
(PhoenixKit.ModuleRegistry.all_media_reorganizers/0), diffs each plan
against the current folder state, and — when asked to apply? — applies
each action in its own transaction. A source raising, a single action
failing, or a naming conflict never halts the run; every outcome is kept
in the report.
Nothing is ever hard-deleted. :trash only soft-deletes a folder that is
still empty (0 files, 0 links, 0 live child folders) at apply time
(PhoenixKit.Modules.Storage.trash_folder/1); the engine never creates
folders.
A real unique-constraint violation aborts the surrounding Postgres
transaction, so a failed UPDATE can't be retried inside the same
transaction. Rather than retry, on_conflict: :suffix SELECTs the target
name for a collision before writing and picks a free "name (N)", so
its UPDATE hits no naming constraint at all; on_conflict: :report
skips the pre-check and lets the UPDATE — the one write its transaction
ever attempts — hit the constraint, reporting :conflict and rolling
back.
{:ok, report} = PhoenixKit.Modules.Storage.Reorganizer.run(actor_uuid, apply?: false)
IO.puts(PhoenixKit.Modules.Storage.Reorganizer.format_report(report))
Summary
Functions
Applies a single normalized action, returning it with :outcome set (and
:error/:reason on anything that isn't a clean success). Runs in one
transaction — a failure at any step rolls back everything the action did.
Renders the report as a fixed-width text table: one row per {source, kind} with columns total moved renamed backfilled restored conflicts failed trashed reported, then a details section — everything not a clean
move (conflicts, failures, reports, trashes, restores), and in dry-run
every planned action, so the owner sees what --apply would do.
Collects and normalizes actions from every source, dropping no-ops.
Never writes (engine-side) — a Source's own hooks may still create
folders/pointers, since plan/2 calls into the module's normal
parent/name resolution.
plan/2, then — when apply?: true — applies every planned action, each
in its own transaction. Never halts: a failure, conflict, or source error
is kept in the report and the run continues.
Resolves the sources: option to a list of Source modules.
Groups actions by {source, kind} and counts outcomes per group.
Functions
@spec apply_one(PhoenixKit.Modules.Storage.Reorganizer.Action.t()) :: PhoenixKit.Modules.Storage.Reorganizer.Action.t()
Applies a single normalized action, returning it with :outcome set (and
:error/:reason on anything that isn't a clean success). Runs in one
transaction — a failure at any step rolls back everything the action did.
Never raises: an exception, throw, or exit anywhere in the apply path
(including an after_move callback) is caught and turned into outcome
:failed — a single bad action never loses the already-committed actions
from the report.
@spec format_report(%{ actions: [PhoenixKit.Modules.Storage.Reorganizer.Action.t()], summary: map(), applied?: boolean() }) :: String.t()
Renders the report as a fixed-width text table: one row per {source, kind} with columns total moved renamed backfilled restored conflicts failed trashed reported, then a details section — everything not a clean
move (conflicts, failures, reports, trashes, restores), and in dry-run
every planned action, so the owner sees what --apply would do.
@spec plan(String.t() | nil, keyword()) :: [ PhoenixKit.Modules.Storage.Reorganizer.Action.t() ]
Collects and normalizes actions from every source, dropping no-ops.
Never writes (engine-side) — a Source's own hooks may still create
folders/pointers, since plan/2 calls into the module's normal
parent/name resolution.
A source's plan/2 is isolated: a raise, throw, exit, or a non-list
return becomes one :source_error :report action for that source and
every other source still runs. Within a source's own list, one action
Action.new!/1 can't normalize (missing/invalid field) becomes one
:invalid_action :report for that action only — the rest of that
source's plan (and every other source) is unaffected.
@spec run(String.t() | nil, keyword()) :: {:ok, %{ actions: [PhoenixKit.Modules.Storage.Reorganizer.Action.t()], summary: map(), applied?: boolean() }}
plan/2, then — when apply?: true — applies every planned action, each
in its own transaction. Never halts: a failure, conflict, or source error
is kept in the report and the run continues.
Resolves the sources: option to a list of Source modules.
:all collects every enabled module's media_reorganizer/0
(PhoenixKit.ModuleRegistry.all_media_reorganizers/0). A list of
module-key strings resolves each to the enabled module's registered
source. A list of modules is used as-is (accepted directly so tests can
exercise a stub Source without registering a fake PhoenixKit.Module).
@spec summarize([PhoenixKit.Modules.Storage.Reorganizer.Action.t()]) :: %{ required({String.t(), atom()}) => map() }
Groups actions by {source, kind} and counts outcomes per group.