# `PhoenixKit.Modules.Storage.Reorganizer.Source`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v2.26.1/lib/modules/storage/reorganizer/source.ex#L1)

Behaviour a module implements to contribute a media-reorganization plan to
`PhoenixKit.Modules.Storage.Reorganizer`.

A module registers its `Source` via the optional `PhoenixKit.Module`
callback `media_reorganizer/0`, collected from **enabled** modules by
`PhoenixKit.ModuleRegistry.all_media_reorganizers/0`.

    @impl PhoenixKit.Module
    def media_reorganizer, do: MyModule.MediaReorganizer

    defmodule MyModule.MediaReorganizer do
      @behaviour PhoenixKit.Modules.Storage.Reorganizer.Source

      @impl true
      def plan(actor_uuid, opts) do
        [
          %{
            source: "my_module",
            kind: :item,
            label: "My item",
            op: :move,
            folder: current_folder,
            parent_uuid: desired_parent_uuid,
            name: desired_name,
            counts: {files, links},
            on_conflict: :suffix
          }
        ]
      end
    end

`plan/2` returns a list of **plain maps** (`PhoenixKit.Modules.Storage.Reorganizer.Action`
validates and normalizes them) — a `Source` implementation never needs to
depend on this module at compile time, only declare the shape. Rules for a
well-behaved `Source` (the contract worked out with the module owner across
several rounds of review; amend this list on conflict rather than a single
module's comments):

- **No hook configured on the host → `:report` actions only.** When the
  module's own parent-folder hook isn't wired up, the `Source` emits
  orphan/pending/duplicate/relocated `:report`s and nothing else — never
  `op: :move`, never `op: :trash`, no pointer back-fill. A host with no
  hooks wired is untouched. A pointer-less module illustrates this with
  `on_conflict: :report` (see below) rather than the `:suffix` example at
  the top of this moduledoc.
- **Desired parent/name come from the module's own hooks** — the same
  functions the module uses when it creates a folder on first upload — so
  a plan always matches what a fresh upload would do.
- **Lookup order is parent-first, in the module's own order**: under the
  resolved parent before root. A host-named folder under the resolved
  parent (the name a fresh upload would use) is itself a lookup step,
  batched — not folded into the legacy-name lookup: if an unclaimed one
  exists there, it IS the current folder (a noop move + pointer
  back-fill); a live host-named folder AND a live legacy-named folder both
  present under/at the same place is one `:report, kind: :duplicate`, no
  move. A live folder found in more than one place the module would look
  (e.g. root AND the resolved parent) is the same: one `:duplicate`
  report naming every uuid found, nothing moves.
- **The record's pointer wins over any name lookup** when it resolves to
  a live folder. A folder found only through a legacy/host name (no live
  pointer) means the record's pointer needs back-filling.
- **A pointer-found folder keeps its current name** (`name: nil`) —
  modules never rename a folder the owner may have renamed — UNLESS
  `folder.name` still equals the module's legacy name exactly (the owner
  never touched it), in which case the desired host name is proposed like
  any other rename. A name hook is never called for a pointer-found
  folder whose name is kept — that decision is made from the folder's own
  name, not from calling the hook.
- **Converging targets**: two records whose desired `{parent, name}` are
  the same collapse into one `:report, kind: :duplicate` for the group —
  never two `:move` actions racing for one target. This only applies when
  the host has a hook configured, and only among actions that would
  otherwise be a real `:move` (a record already sitting at its target is
  not part of the group).
- **Claims are computed from every record's valid pointer**, regardless of
  whether the host has a hook configured — a pending folder any live
  record points at is never planned as an orphan or a stale-pending
  `:trash`, hook or no hook. Orphan candidates always exclude every
  claimed folder (pointer-resolved or name-resolved).
- **No hook call without a candidate** (a record with a resolvable current
  folder) — orphan/pending scanning under a parent that no candidate's
  hook call resolved is scoped to root only, never guessed from where a
  folder happens to sit. The orphan scope is every parent returned by a
  SUCCESSFUL hook call for any candidate, regardless of that candidate's
  own outcome (`:move`, `:relocated`, `:duplicate`, `:hook_nil` all
  count) — never a parent inferred from a folder's current position.
- **A hook answer is `{:ok, uuid}`, `{:ok, nil}`, or bare `nil`** — the
  last two both mean root. Every other shape — a raise, throw, exit,
  `{:error, _}`, `{:ok, <non-UUID>}` (including `{:ok, ""}`), or a
  configured `{module, function}` that isn't callable
  (`Code.ensure_loaded?/1` / `function_exported?/3` fails) — is the hook
  FAILING, never "root". A failing hook skips the affected record with one
  `op: :report, kind: :hook_error` per source (naming how many records it
  affected and, for an uncallable hook, that it "is not callable") — it
  must never be read as `{:ok, nil}` and planned as a move to root. Every
  hook answer is normalized through `Ecto.UUID.cast/1` and downcased
  before use — a non-UUID string must become `:hook_error`, never an
  uncaught `Ecto.Query.CastError` from feeding it straight into a query.
  This applies to the orphan/pending scan too, not only to candidate
  records: a hook call failing while resolving a scope parent is still one
  `:hook_error`, and the exception itself is logged
  (`Logger.warning`) with the module/kind, not only counted.
- **An explicit `nil`/`{:ok, nil}` never moves a folder that isn't already
  at root.** For a candidate whose current folder has a parent, a `nil`
  answer from the parent hook yields only the pointer back-fill (if any)
  and one `:report, kind: :hook_nil` ("hook answered root for a folder
  living under X") — never an actual `:move` to root. Only a folder that
  is already at root may stay there via a `nil` answer.
- **A name hook that fails is a hook error** (`:hook_error`, record
  skipped) — never a silent fallback to the deterministic/legacy name. A
  name hook that itself returns `nil` is fine (it means "use the
  deterministic name").
- **No folder found means no action** — nothing exists to move yet; the
  module creates one on first upload.
- **Pointer-less modules** (no field to back-fill) only ever consider a
  legacy-named folder at root or under the resolved parent; one found
  anywhere else is left alone and reported `kind: :relocated` — never
  moved, since nothing would keep pointing at it afterwards. This applies
  to EVERY extra live copy found (not only the first one), and a copy
  already checked off as a claim is never also reported `:relocated`.
  When the hook itself depends on the acting user (not just database
  state), the `:relocated` reason says so. Every `:relocated` reason names
  WHERE the copy actually is — at the media root, under `<parent name>`,
  or as a `"(N)"` twin already sitting under the target parent — never a
  bare "found elsewhere".
- **Records whose parent record is trashed** (e.g. a CRM interaction of a
  trashed contact) are skipped by the `Source`; their folders, if any, are
  reported as orphans, not moved. Archived/inactive-but-not-deleted
  records are still live for the reorganizer — only a deleted/trashed
  record makes its folder an orphan.
- **Pointer back-fill** happens via `:after_move` when the record has no
  live pointer to its current folder. `:after_move` writes only the
  owned pointer field directly with a repo update (no context `update_*`,
  no Activity log, no PubSub, no full changeset validation) and must be a
  0-arity function returning `:ok`, `{:ok, _}`, or `{:error, _}` (anything
  else is a bad-return failure, same as an `{:error, _}`).
- **Stale pending folders** (a module's own `<prefix>-pending-*` naming):
  empty and older than `pending_days` (an opt threaded through `plan/2`)
  become `op: :trash`; non-empty ones become `op: :report` naming the
  files still inside, collected in one batched files+links query (the
  reason is never an empty list — say "N trashed file(s)" when only
  trashed files remain) — but only among folders no live record claims
  (see claims above), and only for a host that has a hook configured. A
  folder any live record points at is never trashed, and two records
  pointing at the same folder is one `:report, kind: :duplicate`, not a
  move — one folder never produces more than one action.
- A `Source` **must never create folders**. A parent that a module's own
  hook lazily creates (e.g. an "Uncategorized" bucket) is the hook's
  business, not the `Source`'s.
- **Query cost**: load only the light columns a plan needs for CANDIDATE
  DETECTION (uuid, name/number, status, pointer, parent id) — never whole
  records with large/jsonb payloads — in one batched query per kind. When
  the pointer field lives inside a `data` jsonb column, the light select
  pulls it with `fragment("?->>'field_name'", data)` — never `select:
  data` or `select: m` just to reach one key, which drags the whole
  payload along for every row. Also keep the query count independent of
  record count (grouped/batched
  lookups, not one query per record). Every by-name lookup filters
  `trashed_at is nil` (the unique index is partial; a trashed twin must
  never hide a live folder). A pointer that fails `Ecto.UUID.cast/1`, or
  doesn't resolve to a live folder, is treated as absent, never raises;
  pointers are compared case-insensitively (cast + downcased) so casing
  differences never make a pointer look "absent". Once a record is an
  actual CANDIDATE (it has a resolvable current folder), reload it as the
  FULL row in one batched `where uuid in ^candidates` query before calling
  any hook on it — hooks are opaque and may read fields well beyond the
  light select (a light `Category` select missing `parent_uuid` planned
  28 wrong moves to the catalogue root on 2026-09-15). Never call a hook
  for a record that isn't a candidate — there's nothing to move.
- **Deterministic order**: parents before children when the module has
  that hierarchy (e.g. category before item), then by `inserted_at`/uuid,
  so re-running `plan/2` against unchanged data always proposes the same
  order. This holds for orphan/pending candidates too (explicit
  `order_by`), not only for move actions.
- **`counts` contract**: measured at plan time as `{files, links}` where
  `files` is EVERY row of `phoenix_kit_files` with `folder_uuid ==
  folder.uuid` (any status, including trashed) and `links` is every row of
  `phoenix_kit_media_folder_links` with that `folder_uuid` — the engine
  re-counts the same way before and after applying, so a Source must not
  filter either count by status.

# `plan`

```elixir
@callback plan(actor_uuid :: String.t() | nil, opts :: keyword()) :: [map()]
```

Returns the actions this source plans for the current database state.

`actor_uuid` is passed straight through to the module's own hooks (the
same actor a fresh upload would use). `opts` carries engine options such
as `pending_days` (default `7`).

---

*Consult [api-reference.md](api-reference.md) for complete listing*
