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
@spec action() :: String.t()
The activity action a settings change is logged under.
@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.
@spec list(String.t(), keyword()) :: [PhoenixKit.Activity.Entry.t()]
The changes to key, newest first. :limit (default 100).
@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.
@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.
@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}.
@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.