PhoenixKit.Settings.History (phoenix_kit v2.55.1)

Copy Markdown View Source

The history of site settings: what each setting was, and since when.

Why

A stored instant does not say which regime wrote it. When the time_zone setting moved from an integer offset to an IANA id and several modules turned out to have added that value to other instants, the rows they had written could not be repaired: nothing recorded when the setting changed or what it was before. phoenix_kit_settings.date_updated holds the last change only, and no settings writer logged anything.

Where it lives

In the activity feed, as setting.changed entries that are permanent (the pruner keeps them whatever their age), so there is one record of who did what, and the admin's Activity page shows settings changes beside everything else. The entry is inserted inside the settings write's transaction and published to the feed's subscribers only after the commit — nobody hears of a change that rolled back.

Every write through PhoenixKit.Settings that changes a value records one entry, except a write passed history: false (see "Machine stamps" below). The entry's metadata carries the key, the value from and to (a JSON setting as its encoded document), the source ("settings" for the admin pages, "system" otherwise); actor_uuid is the person when one made the change; resource_uuid is the setting row. The value before is read under a row lock inside the write's transaction, so two racing writers cannot both record the same old value. A write that leaves the value as it was records nothing. A restricted (secret) setting records that a change happened — restricted: true, both values withheld. So does an integration connection row, whatever its key.

Machine stamps

A value a machine rewrites on a schedule — job_runs_last_sweep_at, which the jobs sweeper stamps on every five-minute pass — is not a change anybody made, and a permanent entry per pass would bury the feed under rows the pruner never takes. Such a writer passes history: false: the write, the cache invalidation and the settings change broadcast happen as for any other write; only the entry is left out. The option is for a machine's own stamp, never for something a person sets: together with an actor_uuid or source: "settings" it raises ArgumentError (check_options!/1). Every writer checks before its transaction — a refused call takes no lock and writes nothing, whether or not it would have changed the value, an empty batch included — and record/3 checks again for a caller that reaches it directly. No admin page passes the option. list/2 and value_at/2 know nothing about such a key's stamps.

Reading it

  • list/2 — the changes to one key, newest first.
  • value_at/2 — the value a key had at an instant: the answer to "which timezone was this site on when that row was written?".

Summary

Functions

The activity action a settings change is logged under.

Raises ArgumentError when a writer's options ask to skip the history (history: false) for a write that names a person (:actor_uuid) or comes from the admin pages (source: "settings") — those are always recorded. Every writer calls it before its transaction; record/3 calls it too. Returns :ok.

The changes to key, newest first. :limit (default 100).

The current row for key, locked for the rest of the transaction — the "before" a writer hands to record/3. nil when the key does not exist. Call inside a transaction.

Publishes a recorded entry to the feed's subscribers — call after the transaction that wrote it has committed. :unchanged and :unrecorded publish nothing.

Records the change a settings write made, or nothing when it changed no value.

The value key had at instant.

Functions

action()

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

The activity action a settings change is logged under.

check_options!(opts)

@spec check_options!(keyword()) :: :ok

Raises ArgumentError when a writer's options ask to skip the history (history: false) for a write that names a person (:actor_uuid) or comes from the admin pages (source: "settings") — those are always recorded. Every writer calls it before its transaction; record/3 calls it too. Returns :ok.

list(key, opts \\ [])

@spec list(String.t(), keyword()) :: [PhoenixKit.Activity.Entry.t()]

The changes to key, newest first. :limit (default 100).

lock_current(key)

@spec lock_current(String.t()) :: PhoenixKit.Settings.Setting.t() | nil

The current row for key, locked for the rest of the transaction — the "before" a writer hands to record/3. nil when the key does not exist. Call inside a transaction.

publish(entry)

@spec publish(PhoenixKit.Activity.Entry.t() | :unchanged | :unrecorded) :: :ok

Publishes a recorded entry to the feed's subscribers — call after the transaction that wrote it has committed. :unchanged and :unrecorded publish nothing.

record(before, written, opts \\ [])

@spec record(
  PhoenixKit.Settings.Setting.t() | nil,
  PhoenixKit.Settings.Setting.t(),
  keyword()
) ::
  {:ok, PhoenixKit.Activity.Entry.t() | :unchanged | :unrecorded}
  | {:error, Ecto.Changeset.t()}

Records the change a settings write made, or nothing when it changed no value.

before is the row as it was — read under a row lock inside the write's transaction (lock_current/1) — or nil when the key did not exist; written the row as stored. Options: :actor_uuid (nil for a module or a migration), :source ("settings" for the admin pages; default "system"), history: false for a machine stamp (see "Machine stamps" in the moduledoc) — refused with :actor_uuid or source: "settings".

Returns {:ok, %Activity.Entry{}}, {:ok, :unchanged}, {:ok, :unrecorded} (a change written with history: false) or {:error, changeset}.

value_at(key, instant)

@spec value_at(String.t(), DateTime.t() | NaiveDateTime.t()) :: String.t() | nil

The value key had at instant.

The newest change at or before the instant says what the value became; with none, the oldest change after it says what the value was before anything was recorded; with no history at all, the current value — a setting that was never changed since recording began is ASSUMED to have always been what it is now. A JSON setting is its encoded document, the same shape the history holds. A DateTime in any zone is the instant it names, not its wall clock. A restricted key answers nil for every instant (so does an integration connection row): its values are withheld from the history and this must not become the way around that — and a key that WAS restricted when a change was recorded answers nil for that period even after it stops being restricted, because the value was never written down.