# `PhoenixKit.Modules.Storage.ResourceFolders`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v2.41.6/lib/modules/storage/resource_folders.ex#L1)

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.

# `hook_answer`

```elixir
@type hook_answer(value) :: {:ok, value} | :unconfigured | {:error, term()}
```

A hook's answer: a value, not configured, or failed.

# `pointer`

```elixir
@type pointer() :: {:column, atom()} | {atom(), String.t()}
```

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

# `attach`

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

# `claimed?`

```elixir
@spec claimed?(String.t(), String.t() | nil, [{module(), pointer()}]) :: boolean()
```

Whether a record other than `own_uuid` points at `folder_uuid` through
one of `pointers` (`[{schema, pointer}]`, see `t:pointer/0`) — the
check that keeps a record from adopting another's host-named folder.
Fails closed: an error answers `true`.

# `clear_pointer_if`

```elixir
@spec clear_pointer_if(module(), String.t(), pointer(), String.t()) :: :ok
```

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.

# `count_by_folder`

```elixir
@spec count_by_folder([String.t()], keyword()) :: %{
  required(String.t()) =&gt; 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).

# `describe_failure`

```elixir
@spec describe_failure(term()) :: String.t()
```

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.

# `detach`

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

# `ensure`

```elixir
@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 live `name` directly under `parent_uuid`). It runs
    before creating and again after a create is refused, so a caller
    that resolves through `resolve/1` passes that here.
  * `:fallback_name` — when `name` is taken under this parent by a
    folder `:lookup` does 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.

# `files_by_folder`

```elixir
@spec files_by_folder([String.t()], keyword()) :: %{
  required(String.t()) =&gt; [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.

# `files_query`

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

# `find_named`

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

# `find_named_all`

```elixir
@spec find_named_all([String.t()], String.t() | nil, keyword()) :: %{
  required(String.t()) =&gt; PhoenixKit.Modules.Storage.Folder.t()
}
```

`find_named/3` for many names in one query: `%{name => folder}`, a
name with no live folder left out.

# `find_under`

```elixir
@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).

# `holds_file?`

```elixir
@spec holds_file?(String.t() | nil, String.t() | nil, keyword()) :: boolean()
```

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

# `hook_configured?`

```elixir
@spec hook_configured?(atom(), atom()) :: boolean()
```

Whether `app` sets `key` (the parent hook by default) at all.

# `host_name`

```elixir
@spec host_name(atom(), term(), String.t() | nil) :: String.t() | nil
```

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.

# `list_files`

```elixir
@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` — `:newest` first (default) or `:oldest` first
  * `:limit` — at most this many (default 200)

# `live_folder`

```elixir
@spec live_folder(term()) :: PhoenixKit.Modules.Storage.Folder.t() | nil
```

The live folder `uuid` points at, or `nil`; anything not a uuid points nowhere.

# `name_hook`

```elixir
@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}`.

# `name_pending`

```elixir
@spec name_pending(String.t() | nil, String.t(), String.t(), keyword()) :: :ok
```

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 refuses `name` (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.

# `parent_hook`

```elixir
@spec parent_hook(atom(), atom(), String.t() | nil, term()) ::
  hook_answer(String.t() | nil)
```

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}`.

# `parent_uuid`

```elixir
@spec parent_uuid(atom(), atom(), String.t() | nil, term()) :: String.t() | nil
```

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.

# `place_stored`

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

# `point_at`

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

# `pointed_file`

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

# `pointer_value`

```elixir
@spec pointer_value(map(), pointer()) :: String.t() | nil
```

The file uuid `record` stores through `pointer` (`t: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.

# `purge_named`

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

# `resolve`

```elixir
@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:

  1. `:pointer` — the folder uuid the record stores, if that folder is live;
  2. `:host_name` directly under `:parent`, unless `:claimed?` (a
     `fun(folder) -> boolean`) says another record owns that folder;
  3. `:name`, the deterministic name, by `find_named/3` — pass
     `anywhere: true` when it embeds the record's uuid.

Give an unsaved record no names: a folder found by name is one some
saved record already owns.

# `write_pointer`

```elixir
@spec write_pointer(module(), String.t(), pointer(), String.t() | nil) ::
  :ok | {:error, :not_found}
```

Points record `uuid` of `schema` at `folder_uuid` through `pointer`
(`t: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.

---

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