The folder convention for modules that keep one media folder per record (a catalogue item, a location, a CRM contact, a staff person, a project): the host hooks, the lookup order, race-safe find-or-create, and the rules for putting files in and taking them out — written once, so every module places, finds and lists a record's files the same way.
Host hooks
config :my_module, :attachments_parent_folder, {MyApp.Media, :parent_for}
config :my_module, :attachments_folder_name, {MyApp.Media, :folder_name}The parent hook is called as fun(kind, actor_uuid, subject), or as
fun(kind, actor_uuid) when the host exports only that arity, and
answers {:ok, parent_folder_uuid}, or nil / {:ok, nil} for the
media root. The name hook is called as fun(subject, actor_uuid) and
answers {:ok, name}, or nil / {:ok, nil} for the module's
deterministic name.
parent_hook/4 and name_hook/3 tell a hook that is not configured
(:unconfigured) from one that FAILED ({:error, reason}: it raised,
threw, exited, is not exported, or answered anything else — a parent
that is not a uuid included). A media reorganizer must never read a
failure as "the root". parent_uuid/4 and host_name/3 are the forms
for everything else: a failure is logged and falls back to the root /
the deterministic name, so an upload never fails on a host hook. Logs
name a failure's shape only (describe_failure/1) — an exit reason or
an exception message can carry the hook's arguments.
Lookup order
resolve/1 finds a record's folder by the folder uuid the record
stores, then by the host's name under the parent, then by the
deterministic name under the parent, then at the root — and, for a name
that embeds the record's uuid, anywhere. Only live folders count:
(name, parent_uuid) is unique among live folders only, so a trashed
twin can sit beside the live one, and a record whose folder was trashed
gets a new one rather than uploads nobody can see.
A host name carries no uuid, so another record's folder can have it —
claimed?/3 asks whether one points at it, and ensure/4 falls back
to the uuid-bearing name when the host's is taken.
Moving existing folders is the media reorganizer's business; nothing here moves a folder or creates one for people's own use.
Files
A file is IN a folder when the folder is its home (file.folder_uuid)
or a FolderLink puts it there, and it is live (not trashed, not
system-managed). attach/2 and detach/2 follow core's rules
(Storage.attach_file_to_folder/2, Storage.remove_file_from_folder/2):
a file homed elsewhere is linked, never moved; a removed file is
unlinked, re-homed or trashed, never deleted.
Summary
Types
A hook's answer: a value, not configured, or failed.
Where a record keeps its folder uuid: a key of one of its JSONB map
fields ({:data, "files_folder_uuid"}), or a column ({:column, :folder_uuid}).
Functions
Puts a file into folder_uuid by core's attach rule: a file with no
home is adopted (:adopted), a file homed elsewhere is linked
(:linked), a file already home or linked there is left alone
(:already_attached). A folder that is not live is refused
({:error, :folder_unavailable}, nil included), and so is a trashed
file ({:error, :file_trashed}) — either would be listed nowhere.
Never raises.
Whether a record other than own_uuid points at folder_uuid through
one of pointers ([{schema, pointer}], see pointer/0) — the
check that keeps a record from adopting another's host-named folder.
Fails closed: an error answers true.
Removes record uuid's pointer only while it still points at value —
for clearing a pointer to something just removed without wiping one that
another session has pointed elsewhere since. :ok either way.
How many live files each of folder_uuids holds, in two grouped
queries: %{folder_uuid => count}, an empty folder left out. Takes
:only like list_files/2; counts exactly what files_query/1 holds
(list_files/2 lists the same set, up to its :limit)
(a file linked into its own home folder once).
A one-line description of a hook failure, or of any error reason, that leaves out its payload: an exception is named but not rendered (its message interpolates values), an exit reason by its shape.
Takes a file out of folder_uuid by core's removal rule
(Storage.remove_file_from_folder/2): a link is dropped (:unlinked);
a file homed here moves to a live folder that also links it
(:rehomed), or is trashed when nothing else holds it (:trashed).
:absent when the file is gone or was never in the folder — including
nil for the folder, which holds nothing. Never a hard delete; never
raises.
The folder named name under parent_uuid, created when missing —
race-safe: a create that loses to a concurrent one takes the winner.
Never raises.
The files of many folders in two queries: %{folder_uuid => [file]},
each list in :order (:newest first by default), a folder holding
nothing left out. Takes :only like list_files/2; uncapped.
The files folder_uuid holds — home there or linked in — that are
live: not trashed and not system-managed (tile chunks, an edited
image's hidden original, which are never listed). Unordered and
uncapped, for counting or for a caller's own order.
The live folder named name: under parent_uuid first, then at the
root, then — with anywhere: true, for a name that embeds the record's
uuid so that every folder carrying it is that record's — under any
other parent. Oldest first within a place, so the answer never depends
on who asks.
find_named/3 for many names in one query: %{name => folder}, a
name with no live folder left out.
The live folder named name directly under parent_uuid (nil = the root).
Whether live file file_uuid is in folder_uuid — the check that
authorizes pointing a record at one of its own files (an avatar, a
featured image). Takes :only like list_files/2.
Whether app sets key (the parent hook by default) at all.
The host's name for subject's folder: name_hook/3's name, with a
failure logged and nil (use the deterministic name) in its place.
The files folder_uuid holds (files_query/1); [] for nil.
The live folder uuid points at, or nil; anything not a uuid points nowhere.
Asks app's :attachments_folder_name hook what to call subject's
folder: {:ok, name} (trimmed), {:ok, nil} for the deterministic
name, :unconfigured, or {:error, reason}.
Names a pending folder — one created for a record before it was saved,
its name starting with prefix — after its record. A folder that is
not pending is left alone. Always :ok; a failure is logged.
Asks app's :attachments_parent_folder hook where a kind folder
for subject belongs: {:ok, uuid} (cast to its canonical form) or
{:ok, nil} for the root; :unconfigured; or {:error, reason}.
The parent folder for a new kind folder: parent_hook/4's uuid, with
a failure logged and the media root (nil) in its place.
Files a just-stored upload into folder_uuid, given what
Storage.store_file_in_buckets/6 (or store_file/2) answered. Storage
de-duplicates by content, so the answer can be an existing file
Points record uuid of schema at file_uuid through pointer (an
avatar, a featured image) only when folder_uuid holds that live file
(holds_file?/3, taking its :only) — the write a forged file uuid must
not get past. The file's row is locked for the check and the write, the
same lock attach/2 and detach/2 take, so the file cannot leave the
folder in between; the write is write_pointer/4's, one key, so a stale
copy of the record cannot overwrite its other keys.
The live file record points at through pointer, or nil — a missing,
trashed or system-managed file is not shown as anyone's avatar or
featured image.
The file uuid record stores through pointer (pointer/0) — an
avatar in metadata, a featured image in data — or nil when it has
none or holds something that is not a uuid.
Deletes for good every folder named name — wherever it sits, trashed
or not — with everything inside it, for a record deleted for good whose
folder name embeds its uuid. A file some other folder links keeps
living there (Storage.delete_folder_completely/1). Always :ok; a
failure is logged.
A record's folder, in the convention's order, or nil when it has none
yet
Points record uuid of schema at folder_uuid through pointer
(pointer/0) — one UPDATE of that key or column only, no changeset,
no callbacks, the rest of the row untouched; nil removes the pointer.
{:error, :not_found} when no such record exists.
Types
@type hook_answer(value) :: {:ok, value} | :unconfigured | {:error, term()}
A hook's answer: a value, not configured, or failed.
Where a record keeps its folder uuid: a key of one of its JSONB map
fields ({:data, "files_folder_uuid"}), or a column ({:column, :folder_uuid}).
Functions
@spec attach(PhoenixKit.Modules.Storage.File.t() | String.t(), String.t() | nil) :: {:ok, :adopted | :linked | :already_attached} | {:error, term()}
Puts a file into folder_uuid by core's attach rule: a file with no
home is adopted (:adopted), a file homed elsewhere is linked
(:linked), a file already home or linked there is left alone
(:already_attached). A folder that is not live is refused
({:error, :folder_unavailable}, nil included), and so is a trashed
file ({:error, :file_trashed}) — either would be listed nowhere.
Never raises.
The folder's row is taken before the file's, the order the reorganizer's move takes them in.
Whether a record other than own_uuid points at folder_uuid through
one of pointers ([{schema, pointer}], see pointer/0) — the
check that keeps a record from adopting another's host-named folder.
Fails closed: an error answers true.
Removes record uuid's pointer only while it still points at value —
for clearing a pointer to something just removed without wiping one that
another session has pointed elsewhere since. :ok either way.
@spec count_by_folder([String.t()], keyword()) :: %{ required(String.t()) => pos_integer() }
How many live files each of folder_uuids holds, in two grouped
queries: %{folder_uuid => count}, an empty folder left out. Takes
:only like list_files/2; counts exactly what files_query/1 holds
(list_files/2 lists the same set, up to its :limit)
(a file linked into its own home folder once).
A one-line description of a hook failure, or of any error reason, that leaves out its payload: an exception is named but not rendered (its message interpolates values), an exit reason by its shape.
@spec detach(PhoenixKit.Modules.Storage.File.t() | String.t(), String.t() | nil) :: {:ok, :unlinked | :rehomed | :trashed | :absent} | {:error, term()}
Takes a file out of folder_uuid by core's removal rule
(Storage.remove_file_from_folder/2): a link is dropped (:unlinked);
a file homed here moves to a live folder that also links it
(:rehomed), or is trashed when nothing else holds it (:trashed).
:absent when the file is gone or was never in the folder — including
nil for the folder, which holds nothing. Never a hard delete; never
raises.
@spec ensure(String.t(), String.t() | nil, String.t() | nil, keyword()) :: {:ok, PhoenixKit.Modules.Storage.Folder.t()} | {:error, term()}
The folder named name under parent_uuid, created when missing —
race-safe: a create that loses to a concurrent one takes the winner.
Never raises.
Options
:lookup— a 0-arity function finding the existing folder (default: the livenamedirectly underparent_uuid). It runs before creating and again after a create is refused, so a caller that resolves throughresolve/1passes that here.:fallback_name— whennameis taken under this parent by a folder:lookupdoes not adopt (another record's), or core refuses it, create this one instead: the uuid-bearing deterministic name, which cannot collide. A name known to be taken is not tried at all.:claim— a 1-arity function recording the folder as the record's (writing its pointer,write_pointer/4), answering:ok,{:ok, _}or{:error, _}. With it, the lookup, the create and the claim run in one transaction under a lock on{parent, name}: a folder found by a name that carries no uuid is claimed before anyone else can look for it, so two same-named records resolving at once never share one folder. A claim that fails rolls the create back.
@spec files_by_folder([String.t()], keyword()) :: %{ required(String.t()) => [PhoenixKit.Modules.Storage.File.t()] }
The files of many folders in two queries: %{folder_uuid => [file]},
each list in :order (:newest first by default), a folder holding
nothing left out. Takes :only like list_files/2; uncapped.
@spec files_query(String.t()) :: Ecto.Query.t()
The files folder_uuid holds — home there or linked in — that are
live: not trashed and not system-managed (tile chunks, an edited
image's hidden original, which are never listed). Unordered and
uncapped, for counting or for a caller's own order.
@spec find_named(String.t(), String.t() | nil, keyword()) :: PhoenixKit.Modules.Storage.Folder.t() | nil
The live folder named name: under parent_uuid first, then at the
root, then — with anywhere: true, for a name that embeds the record's
uuid so that every folder carrying it is that record's — under any
other parent. Oldest first within a place, so the answer never depends
on who asks.
@spec find_named_all([String.t()], String.t() | nil, keyword()) :: %{ required(String.t()) => PhoenixKit.Modules.Storage.Folder.t() }
find_named/3 for many names in one query: %{name => folder}, a
name with no live folder left out.
@spec find_under(String.t(), String.t() | nil) :: PhoenixKit.Modules.Storage.Folder.t() | nil
The live folder named name directly under parent_uuid (nil = the root).
Whether live file file_uuid is in folder_uuid — the check that
authorizes pointing a record at one of its own files (an avatar, a
featured image). Takes :only like list_files/2.
Whether app sets key (the parent hook by default) at all.
The host's name for subject's folder: name_hook/3's name, with a
failure logged and nil (use the deterministic name) in its place.
@spec list_files(String.t() | nil, keyword()) :: [PhoenixKit.Modules.Storage.File.t()]
The files folder_uuid holds (files_query/1); [] for nil.
Options
:only—:images,:non_images,{:type, file_type},{:not_type, file_type}or:all(default):order—:newestfirst (default) or:oldestfirst:limit— at most this many (default 200)
@spec live_folder(term()) :: PhoenixKit.Modules.Storage.Folder.t() | nil
The live folder uuid points at, or nil; anything not a uuid points nowhere.
@spec name_hook(atom(), term(), String.t() | nil) :: hook_answer(String.t() | nil)
Asks app's :attachments_folder_name hook what to call subject's
folder: {:ok, name} (trimmed), {:ok, nil} for the deterministic
name, :unconfigured, or {:error, reason}.
Names a pending folder — one created for a record before it was saved,
its name starting with prefix — after its record. A folder that is
not pending is left alone. Always :ok; a failure is logged.
Options
:fallback_name— used when core refusesname(taken under the folder's parent):move_to— also move the folder under this parent (nil= the root), for a module whose parent depends on the saved record. Pass only a definite answer (parent_hook/4's{:ok, parent}), never the root a failed hook fell back to. Without it only the name changes: an explicit parent in an update is a move.
Asks app's :attachments_parent_folder hook where a kind folder
for subject belongs: {:ok, uuid} (cast to its canonical form) or
{:ok, nil} for the root; :unconfigured; or {:error, reason}.
The parent folder for a new kind folder: parent_hook/4's uuid, with
a failure logged and the media root (nil) in its place.
@spec place_stored(term(), String.t()) :: {:ok, PhoenixKit.Modules.Storage.File.t()} | {:already_attached, PhoenixKit.Modules.Storage.File.t()} | {:error, term()}
Files a just-stored upload into folder_uuid, given what
Storage.store_file_in_buckets/6 (or store_file/2) answered. Storage
de-duplicates by content, so the answer can be an existing file:
- a trashed duplicate is restored — the person removed it and is uploading it again — and attached as if new;
- a duplicate already in the folder is
{:already_attached, file}, so the uploader hears that nothing was added; - anything else is attached:
{:ok, file}.
Never raises.
@spec point_at( module(), String.t(), pointer(), String.t(), String.t() | nil, keyword() ) :: :ok | {:error, :not_held | :not_found | term()}
Points record uuid of schema at file_uuid through pointer (an
avatar, a featured image) only when folder_uuid holds that live file
(holds_file?/3, taking its :only) — the write a forged file uuid must
not get past. The file's row is locked for the check and the write, the
same lock attach/2 and detach/2 take, so the file cannot leave the
folder in between; the write is write_pointer/4's, one key, so a stale
copy of the record cannot overwrite its other keys.
:ok, {:error, :not_held}, {:error, :not_found} for no such record,
or {:error, reason}; never raises.
@spec pointed_file(map(), pointer()) :: PhoenixKit.Modules.Storage.File.t() | nil
The live file record points at through pointer, or nil — a missing,
trashed or system-managed file is not shown as anyone's avatar or
featured image.
The file uuid record stores through pointer (pointer/0) — an
avatar in metadata, a featured image in data — or nil when it has
none or holds something that is not a uuid.
@spec purge_named(String.t()) :: :ok
Deletes for good every folder named name — wherever it sits, trashed
or not — with everything inside it, for a record deleted for good whose
folder name embeds its uuid. A file some other folder links keeps
living there (Storage.delete_folder_completely/1). Always :ok; a
failure is logged.
A name with no uuid in it is refused (logged, nothing deleted): it is
matched across every folder in the install, so a plain name such as
"Invoices" would take people's own folders of that name with it.
@spec resolve(keyword()) :: PhoenixKit.Modules.Storage.Folder.t() | nil
A record's folder, in the convention's order, or nil when it has none
yet:
:pointer— the folder uuid the record stores, if that folder is live;:host_namedirectly under:parent, unless:claimed?(afun(folder) -> boolean) says another record owns that folder;:name, the deterministic name, byfind_named/3— passanywhere: truewhen it embeds the record's uuid.
Give an unsaved record no names: a folder found by name is one some saved record already owns.
@spec write_pointer(module(), String.t(), pointer(), String.t() | nil) :: :ok | {:error, :not_found}
Points record uuid of schema at folder_uuid through pointer
(pointer/0) — one UPDATE of that key or column only, no changeset,
no callbacks, the rest of the row untouched; nil removes the pointer.
{:error, :not_found} when no such record exists.