PhoenixKit. Modules. Storage. Reorganizer. Source behaviour
(phoenix_kit v2.26.1)
Copy Markdown
View Source
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
endplan/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 →
:reportactions only. When the module's own parent-folder hook isn't wired up, theSourceemits orphan/pending/duplicate/relocated:reports and nothing else — neverop: :move, neverop: :trash, no pointer back-fill. A host with no hooks wired is untouched. A pointer-less module illustrates this withon_conflict: :report(see below) rather than the:suffixexample 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:duplicatereport 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 — UNLESSfolder.namestill 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: :duplicatefor the group — never two:moveactions 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_nilall count) — never a parent inferred from a folder's current position. - A hook answer is
{:ok, uuid},{:ok, nil}, or barenil— 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?/3fails) — is the hook FAILING, never "root". A failing hook skips the affected record with oneop: :report, kind: :hook_errorper 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 throughEcto.UUID.cast/1and downcased before use — a non-UUID string must become:hook_error, never an uncaughtEcto.Query.CastErrorfrom 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, anilanswer 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:moveto root. Only a folder that is already at root may stay there via anilanswer. - 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 returnsnilis 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:relocatedreason says so. Every:relocatedreason 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_movewhen the record has no live pointer to its current folder.:after_movewrites only the owned pointer field directly with a repo update (no contextupdate_*, 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 thanpending_days(an opt threaded throughplan/2) becomeop: :trash; non-empty ones becomeop: :reportnaming 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
Sourcemust never create folders. A parent that a module's own hook lazily creates (e.g. an "Uncategorized" bucket) is the hook's business, not theSource'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
datajsonb column, the light select pulls it withfragment("?->>'field_name'", data)— neverselect: dataorselect: mjust 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 filterstrashed_at is nil(the unique index is partial; a trashed twin must never hide a live folder). A pointer that failsEcto.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 batchedwhere uuid in ^candidatesquery before calling any hook on it — hooks are opaque and may read fields well beyond the light select (a lightCategoryselect missingparent_uuidplanned 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-runningplan/2against unchanged data always proposes the same order. This holds for orphan/pending candidates too (explicitorder_by), not only for move actions. countscontract: measured at plan time as{files, links}wherefilesis EVERY row ofphoenix_kit_fileswithfolder_uuid == folder.uuid(any status, including trashed) andlinksis every row ofphoenix_kit_media_folder_linkswith thatfolder_uuid— the engine re-counts the same way before and after applying, so a Source must not filter either count by status.
Summary
Callbacks
Returns the actions this source plans for the current database state.
Callbacks
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).