PhoenixKit.Modules.Storage.ResourceFolders (phoenix_kit v2.40.1)

Copy Markdown View Source

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

hook_answer(value)

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

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

pointer()

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

Functions

attach(file_or_uuid, folder_uuid)

@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?(folder_uuid, own_uuid, pointers)

@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 pointer/0) — the check that keeps a record from adopting another's host-named folder. Fails closed: an error answers true.

clear_pointer_if(schema, uuid, pointer, value)

@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(folder_uuids, opts \\ [])

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

describe_failure(reason)

@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(file_or_uuid, folder_uuid)

@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(name, parent_uuid, actor_uuid, opts \\ [])

@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(folder_uuids, opts \\ [])

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

files_query(folder_uuid)

@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(name, parent_uuid, opts \\ [])

@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(names, parent_uuid, opts \\ [])

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

find_under(name, parent_uuid)

@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?(folder_uuid, file_uuid, opts \\ [])

@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?(app, key \\ :attachments_parent_folder)

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

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

host_name(app, subject, actor_uuid)

@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(folder_uuid, opts \\ [])

@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(uuid)

@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(app, subject, actor_uuid)

@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(folder_uuid, prefix, name, opts \\ [])

@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(app, kind, actor_uuid, subject)

@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(app, kind, actor_uuid, subject)

@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(arg, folder_uuid)

@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(schema, uuid, pointer, file_uuid, folder_uuid, opts \\ [])

@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(record, pointer)

@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(record, arg)

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

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.

purge_named(name)

@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(opts)

@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(schema, uuid, pointer, folder_uuid)

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