PhoenixKit.Modules.Storage.Reorganizer (phoenix_kit v2.26.1)

Copy Markdown View Source

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

apply_one(action)

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.

format_report(map)

@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.

plan(actor_uuid, opts \\ [])

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.

run(actor_uuid, opts \\ [])

@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.

sources(list)

@spec sources(:all | [String.t()] | [module()]) :: [module()]

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).

summarize(actions)

@spec summarize([PhoenixKit.Modules.Storage.Reorganizer.Action.t()]) :: %{
  required({String.t(), atom()}) => map()
}

Groups actions by {source, kind} and counts outcomes per group.