DB-side merge for the unified activity log. Builds one UNION ALL across every
discovered AshPaperTrail version table, AshEvents event-log table, and the
oban_jobs table, then lets Postgres do the filtering, ordering and pagination
in a single round-trip — instead of fetching a wide window from each source and
merging in memory.
Why it is shaped this way (so indexes are actually usable)
Every filter and the ORDER BY … LIMIT are pushed DOWN into each branch, and
the branch caps itself at offset + limit rows. The global top-K can only come
from each branch's own top-K, so merging the per-branch tops and re-sorting a
tiny set is correct. This lets each branch use an index:
- tenant scope is
site_id = $1::uuid(raw uuid, NOT a text cast) so the(site_id, version_inserted_at)composite index is reachable; ORDER BY version_inserted_at DESC LIMIT krides that index (a bare(version_inserted_at)index serves the master path, where there is no site filter), instead of a Seq Scan + Sort over the whole table;kindselects which branches run;severityis a branch-level predicate (and whole branches are skipped when they cannot match, e.g. version/event branches forseverity = "error").
No COUNT(*) OVER (): the :load_more table never needs an exact total
(gervaz derives more? from Ash's page_size+1 extra row), and an exact count
forces Postgres to materialise every matching row — defeating the early-LIMIT.
counts/1 is the deliberate exception — a separate, capped query for the page's
KPI row, run off the render path, never joined onto page/1.
Discovery is dynamic (MishkaCmsCore.Activity.Discovery): the query adapts to
whatever extensions are installed, with no hardcoded resource or table names.
Per-source presentation (label, message, badge, icon) is applied in Elixir over
only the page's rows via each source's entry_from_row/1.
See MishkaCmsCore.Activity.ActivityEntry (calls page/1 from its manual read)
and the source adapters MishkaCmsCore.Activity.Sources.PaperTrail,
MishkaCmsCore.Activity.Sources.EventLog, MishkaCmsCore.Activity.Sources.Oban.
Summary
Functions
Bulk-resolve actor user_ids to %{email, site_id} in ONE query — so the table's
actor column doesn't Ash.get per row (an N+1 that also dragged in the user's
permissions/site loads). id = ANY($1::text[]::uuid[]) keeps the param text
(Postgrex-friendly) while comparing against the un-cast uuid pkey.
How many entries page/1 has to show, as %{total: n, info: n, warn: n, error: n} — the log's KPI
row and its "showing 1–20 of N" label.
How many entries fall on each day, as %{~D[2026-07-16] => 15} — the "15 events" beside a day's
heading in the timeline.
The most rows any one branch is scanned for — a count that reaches it means "or more".
Runs the unified activity query for one page and returns the entry maps (the
MishkaCmsCore.Activity.ActivityEntry shape), already ordered newest-first.
Humanise a "Elixir.A.B.Thing" resource string to "Thing" (spaced).
Grouped options for the resource/source :select filter — one {extension_label, [{resource_label, module_string}]} group per extension (so same-named resources
in different extensions, e.g. Blog vs Document "Tag", stay distinct).
Covers version parent modules + distinct event-log resource values; the value
is the module string the SQL filters on.
Whether a resource shares its global (site_id nil) rows with tenants — true iff
it exposes the :for_tenant_with_globals read, the same rule its own admin page
uses (Component, Layout, JsHook, Stylesheet). Tenants see global activity only for
these; everything else (Page, ErrorPage, Media, blog, document, …) stays
own-site-only for tenants.
Bulk-resolve site ids to their name in ONE query (archived included).
How many entries match opts in total — what page/1 is showing a window of.
Functions
Bulk-resolve actor user_ids to %{email, site_id} in ONE query — so the table's
actor column doesn't Ash.get per row (an N+1 that also dragged in the user's
permissions/site loads). id = ANY($1::text[]::uuid[]) keeps the param text
(Postgrex-friendly) while comparing against the un-cast uuid pkey.
Scoped to the viewer: a site user loads only its own site's users, so another
site's or a master's email never reaches the request. A master (a user whose
site_id is nil) loads any actor. No viewer, or anything that is not a user,
loads nothing. MishkaCmsCore.Activity.Helpers.actor_labels/2 names the rest.
@spec counts(map()) :: %{ total: non_neg_integer(), info: non_neg_integer(), warn: non_neg_integer(), error: non_neg_integer() }
How many entries page/1 has to show, as %{total: n, info: n, warn: n, error: n} — the log's KPI
row and its "showing 1–20 of N" label.
Takes the same opts as page/1 minus :severity, :limit and :offset: each figure is a COUNT
over the very same UNION, so a count always agrees with the list beside it. Like page/1, every
branch is capped at @max_cap, so a log past that reports the cap rather than scanning forever.
@spec day_counts(map()) :: %{required(Date.t()) => non_neg_integer()}
How many entries fall on each day, as %{~D[2026-07-16] => 15} — the "15 events" beside a day's
heading in the timeline.
Takes the same opts as counts/1. One GROUP BY over the same UNION, so the figure is the day's REAL
total rather than however much of that day happens to be loaded — the log pages with load_more, and
a heading that counted only the loaded rows would keep changing as you scroll.
@spec max_cap() :: pos_integer()
The most rows any one branch is scanned for — a count that reaches it means "or more".
Runs the unified activity query for one page and returns the entry maps (the
MishkaCmsCore.Activity.ActivityEntry shape), already ordered newest-first.
Opts:
:site_id— the one site to read;nilreads every site, which only a master's reach asks for (MishkaCmsCore.Activity.Helpers.reach/1):kind—"content"|"job"|nil:severity—"info"|"warn"|"error"|nil:resources—:all(the default) or the"Elixir.Module"strings whose entries are shown, asMishkaCmsCore.Activity.Helpers.resources_for/1answers for a viewer:limit— page size (Ash sets this topage_size + 1somore?works):offset— page offset
Humanise a "Elixir.A.B.Thing" resource string to "Thing" (spaced).
Grouped options for the resource/source :select filter — one {extension_label, [{resource_label, module_string}]} group per extension (so same-named resources
in different extensions, e.g. Blog vs Document "Tag", stay distinct).
Covers version parent modules + distinct event-log resource values; the value
is the module string the SQL filters on.
Bulk-resolve site ids to their name in ONE query (archived included).
@spec total(map()) :: non_neg_integer()
How many entries match opts in total — what page/1 is showing a window of.
Same opts as page/1 minus :limit/:offset. The read hands this to Ash as full_count so the
table can say how far into the log a page reaches; Ash.DataLayer.Simple cannot count a manual
read by itself. Capped like page/1, so a log past @max_cap reports the cap.