PhoenixKit.Modules.Storage.Audit (phoenix_kit v2.62.2)

Copy Markdown View Source

The "who changed what" half of Media's history (dev_docs/plans/2026-10-03-job-runs.md, §7): every change to the site's storage configuration is written to the Activity log, and the action names live here, in one place. Settings → Media → History reads them back, together with the entries of the storage job runs.

actionresource
storage.profile.created / updated / deletedstorage_profile
storage.profile.bucket_added / bucket_changed / bucket_removedstorage_profile
storage.library.created / renamed / deletedstorage_library
storage.library.profile_changed / variant_set_changed / setting_changedstorage_library
storage.variant_set.created / updated / deleted / remadestorage_variant_set
storage.variant_set.size_created / size_updated / size_deleted / sizes_resetstorage_variant_set
storage.bucket.created / updated / deletedstorage_bucket
storage.copy.damagedbucket — a copy found missing or not matching its checksum
storage.file.repairedfile — what a repair of one file did

The last two are the trail of storage that went wrong: a bucket that keeps losing or changing objects is a disk to look at, and damaged_copies/2 is how a bucket's page says so.

An entry carries the acting user (actor_uuid: in the context's options; the LiveViews pass PhoenixKitWeb.Actor.opts(socket)), a mode (manual when a person acted, auto when nothing did) and, for a change, the Activity log's own "changes" shape — %{"copies_originals" => %{"from" => 1, "to" => 2}} — which the feed already renders as "from → to". Entries are permanent: they are not pruned by activity_retention_days, because "who changed this bucket last year" is exactly what an audit is asked.

Only the site's configuration is recorded. A user's own library, profile and bucket (V203–V206) are theirs and private; their changes are not written here. And nothing secret is ever put in an entry: bucket changes name only the fields in bucket_fields/0, never a key or a secret.

Summary

Functions

Defers a cache invalidation or compatibility-settings sync until this module's outer transaction commits. Callbacks are best effort. Inside a caller-owned repo transaction it retains the callback's existing immediate behavior; callers needing commit ordering must use transaction/1 as their outer boundary.

The bucket fields whose changes are recorded: never a key or a secret.

Locks and reloads an audited resource so diffs describe its actual preceding state.

The "changes" map of an update: for each of fields that the changeset changes, %{"field" => %{"from" => old, "to" => new}} (values made loggable). Empty when nothing in fields changed.

How many damaged copies were found in the bucket in the last days days (distinct object keys among the storage.copy.damaged entries, so verifying the same copy twice counts it once), and when the last was: %{count:, last_at:}.

The "changes" map between two values of the same struct (before, after) for fields. Empty when none differs.

Writes one configuration entry. Insert failures roll back an enclosing audited mutation; a standalone call returns the error. Entries are announced only after the transaction owned by this module commits.

Writes one storage.copy.damaged entry for each result of a verification (FileReport.verify/1) that is a copy missing from its bucket or differing from its checksum, against that bucket. A copy that could not be read is not damage (the bucket may only be unreachable), and a user's own bucket is private. opts are the audit options (:actor_uuid) and :found_by ("verify" or "repair"). Never raises.

Writes the storage.file.repaired entry of a repair of file: the actions it took (rendition, what, which bucket) and how many problems were left. Nothing is written when nothing was done. Never raises.

Logs an update, when changes is not empty; extra is merged into the metadata.

The Activity module key every storage entry (configuration and runs) is filed under.

Runs a configuration mutation and its audit inserts in one transaction, then announces the entries after commit. Nested audited mutations share the entries. A failed mutation or audit insert rolls everything back.

Functions

after_commit(fun)

@spec after_commit((-> term())) :: :ok

Defers a cache invalidation or compatibility-settings sync until this module's outer transaction commits. Callbacks are best effort. Inside a caller-owned repo transaction it retains the callback's existing immediate behavior; callers needing commit ordering must use transaction/1 as their outer boundary.

bucket_fields()

@spec bucket_fields() :: [atom()]

The bucket fields whose changes are recorded: never a key or a secret.

change(map, fun)

@spec change(struct(), (struct() -> result)) :: result | {:error, term()}
when result: var

Locks and reloads an audited resource so diffs describe its actual preceding state.

changes(changeset, fields)

@spec changes(Ecto.Changeset.t(), [atom()]) :: %{required(String.t()) => map()}

The "changes" map of an update: for each of fields that the changeset changes, %{"field" => %{"from" => old, "to" => new}} (values made loggable). Empty when nothing in fields changed.

damaged_copies(bucket_uuid, days \\ 30)

@spec damaged_copies(term(), pos_integer()) :: %{
  count: non_neg_integer(),
  last_at: term()
}

How many damaged copies were found in the bucket in the last days days (distinct object keys among the storage.copy.damaged entries, so verifying the same copy twice counts it once), and when the last was: %{count:, last_at:}.

diff(before, later, fields)

@spec diff(map(), map(), [atom()]) :: %{required(String.t()) => map()}

The "changes" map between two values of the same struct (before, after) for fields. Empty when none differs.

log(action, resource_type, resource_uuid, opts, metadata \\ %{})

@spec log(String.t(), String.t(), String.t() | nil, keyword(), map()) ::
  {:ok, PhoenixKit.Activity.Entry.t()} | {:error, term()} | :skipped

Writes one configuration entry. Insert failures roll back an enclosing audited mutation; a standalone call returns the error. Entries are announced only after the transaction owned by this module commits.

opts are the context call's: :actor_uuid, and :mode (default "manual" with an actor, "auto" without). audit: false writes nothing (:skipped) — for a change one context makes to another's rows as a consequence of the one that is recorded. metadata is a map of string keys.

log_damage(file, results, opts)

@spec log_damage(PhoenixKit.Modules.Storage.File.t(), [map()], keyword()) :: :ok

Writes one storage.copy.damaged entry for each result of a verification (FileReport.verify/1) that is a copy missing from its bucket or differing from its checksum, against that bucket. A copy that could not be read is not damage (the bucket may only be unreachable), and a user's own bucket is private. opts are the audit options (:actor_uuid) and :found_by ("verify" or "repair"). Never raises.

log_repair(file, actions, problems_left, opts)

@spec log_repair(
  PhoenixKit.Modules.Storage.File.t(),
  [map()],
  non_neg_integer(),
  keyword()
) :: :ok

Writes the storage.file.repaired entry of a repair of file: the actions it took (rendition, what, which bucket) and how many problems were left. Nothing is written when nothing was done. Never raises.

log_update(action, resource_type, uuid, opts, changes, extra \\ %{})

@spec log_update(String.t(), String.t(), String.t() | nil, keyword(), map(), map()) ::
  :ok

Logs an update, when changes is not empty; extra is merged into the metadata.

module_key()

@spec module_key() :: String.t()

The Activity module key every storage entry (configuration and runs) is filed under.

transaction(fun)

@spec transaction((-> result)) :: result | {:error, term()} when result: var

Runs a configuration mutation and its audit inserts in one transaction, then announces the entries after commit. Nested audited mutations share the entries. A failed mutation or audit insert rolls everything back.

Inside a caller's own repo transaction, entries commit with that transaction but are not announced: this module cannot know when the caller commits. The History tab's periodic refresh picks them up. Call this wrapper at the outer boundary when immediate announcements are wanted.